feat(ccpm): 中金所成交持仓排名采集——CLI/API/WebUI 三端 + 新手科普

- 新增 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
This commit is contained in:
GitHub
2026-09-02 20:54:59 +08:00
parent 06b5d380d5
commit 99397a12e3
16 changed files with 1820 additions and 0 deletions
+3
View File
@@ -37,3 +37,6 @@ audit-report-*.html
web-ui/e2e/.results/
web-ui/test-results/
web-ui/playwright-report/
# ZCode agent 会话目录(本地,不入库)
.zcode/
+15
View File
@@ -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 / 通义千问 / 智谱 GLMbigmodel.cn/ Kimi / MiniMax / OpenAI / ClaudeAnthropic 原生协议)/ Ollama(本地免 Key)/ 自定义(任意 OpenAI 兼容网关),base_url 与模型均可覆盖。配置落盘 `~/.easy_tdx/llm.json`(随 `EASY_TDX_CONFIG_DIR`),WebUI 表单与手工编辑同一份文件、双向兼容;字段级优先级 = 文件 > 环境变量(`LLM_PROVIDER`/`LLM_API_KEY`/`LLM_BASE_URL`/`LLM_MODEL`> 预设默认。APIGET/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 零真实网络)。
+18
View File
@@ -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/`),历史二次查询零网络。页面附三段新手科普:「(代客)」是什么意思、多单/空单/加减仓怎么看、以及最重要的一条——**排名表看不出是套保还是投机,空单多 ≠ 看空市场**(股指期货空单大头常是机构套保盘)。
<img src="./docs/web-ui-page-4.png" alt="行情终端 Web UIv1.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 持久化),勾选多个策略做资金分仓组合回测看综合表现,全程零代码。
+37
View File
@@ -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",
]
+314
View File
@@ -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 urllibmonkeypatch 点)。
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 长表结构(每条记录一个 ``<data>`` 节点)::
<positionRank><data>
<instrumentid>IF2609</instrumentid> <tradingday>20260902</tradingday>
<datatypeid>1</datatypeid> <rank>1</rank>
<shortname>国泰君安(代客)</shortname> <volume>22066</volume>
<varvolume>-789</varvolume> <partyid>0001</partyid> <productid>IF</productid>
</data>...</positionRank>
某合约某类型缺某排名时对应单元格置 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
+130
View File
@@ -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()]
+2
View File
@@ -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)
+98
View File
@@ -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))
+3
View File
@@ -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
+88
View File
@@ -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)
+392
View File
@@ -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 XML1 个合约 × 3 类排名 × 前 2 名)
# ---------------------------------------------------------------------------
_SAMPLE_XML = """<?xml version="1.0" encoding="UTF-8"?>
<positionRank>
<data Value="1" Text="IF2609">
<instrumentid>IF2609</instrumentid><tradingday>20260902</tradingday>
<datatypeid>0</datatypeid><rank>1</rank>
<shortname>甲期货(代客)</shortname><volume>100</volume><varvolume>10</varvolume>
<partyid>0001</partyid><productid>IF</productid>
</data>
<data Value="1" Text="IF2609">
<instrumentid>IF2609</instrumentid><tradingday>20260902</tradingday>
<datatypeid>0</datatypeid><rank>2</rank>
<shortname>乙期货(代客)</shortname><volume>80</volume><varvolume>-5</varvolume>
<partyid>0002</partyid><productid>IF</productid>
</data>
<data Value="1" Text="IF2609">
<instrumentid>IF2609</instrumentid><tradingday>20260902</tradingday>
<datatypeid>1</datatypeid><rank>1</rank>
<shortname>丙期货(代客)</shortname><volume>220</volume><varvolume>-7</varvolume>
<partyid>0003</partyid><productid>IF</productid>
</data>
<data Value="1" Text="IF2609">
<instrumentid>IF2609</instrumentid><tradingday>20260902</tradingday>
<datatypeid>1</datatypeid><rank>2</rank>
<shortname>甲期货(代客)</shortname><volume>180</volume><varvolume>0</varvolume>
<partyid>0001</partyid><productid>IF</productid>
</data>
<data Value="1" Text="IF2609">
<instrumentid>IF2609</instrumentid><tradingday>20260902</tradingday>
<datatypeid>2</datatypeid><rank>1</rank>
<shortname>乙期货(代客)</shortname><volume>150</volume><varvolume>3</varvolume>
<partyid>0002</partyid><productid>IF</productid>
</data>
<data Value="1" Text="IF2609">
<instrumentid>IF2609</instrumentid><tradingday>20260902</tradingday>
<datatypeid>2</datatypeid><rank>2</rank>
<shortname>丙期货(代客)</shortname><volume>90</volume><varvolume>-2</varvolume>
<partyid>0003</partyid><productid>IF</productid>
</data>
</positionRank>
"""
@pytest.fixture()
def isolated_config(tmp_path, monkeypatch):
"""缓存目录隔离到 tmp_pathEASY_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(
"<datatypeid>1</datatypeid>", "<datatypeid>9</datatypeid>"
).replace("<datatypeid>2</datatypeid>", "<datatypeid>9</datatypeid>")
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
+1
View File
@@ -28,6 +28,7 @@ const sseLabel: Record<string, string> = {
<RouterLink to="/" exact-active-class="active">市场看板</RouterLink>
<RouterLink to="/watchlist" active-class="active">自选行情</RouterLink>
<RouterLink to="/leaders" active-class="active">龙头池</RouterLink>
<RouterLink to="/ccpm" active-class="active">期货持仓排名</RouterLink>
<div class="nav-group">分析</div>
<RouterLink to="/backtest" active-class="active">单标的回测</RouterLink>
<RouterLink to="/portfolio" active-class="active">组合回测</RouterLink>
+19
View File
@@ -9,6 +9,8 @@ import type {
BoardRow,
Category,
CoreLeaderRow,
CcpmProductsResponse,
CcpmRankResponse,
DataFrameResponse,
LlmChatResponse,
LlmChatContext,
@@ -746,3 +748,20 @@ export async function fetchCoreLeaders(): Promise<CoreLeaderRow[]> {
const body = (await resp.json()) as DataFrameResponse
return body.data as unknown as CoreLeaderRow[]
}
/** 中金所成交持仓排名:品种列表(含科普元数据)。 */
export async function fetchCcpmProducts(): Promise<CcpmProductsResponse> {
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<CcpmRankResponse> {
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
}
+3
View File
@@ -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: '/' },
]
+43
View File
@@ -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
}
+654
View File
@@ -0,0 +1,654 @@
<script setup lang="ts">
// 中金所成交持仓排名页(ccpm):官网每日收盘后约 16:15 发布的
// 「成交量 / 持买单量 / 持卖单量」前 20 名会员数据。
// 下拉选品种 + 日期选择器 + 一键采集;附新手科普(品种含义 + 多空/加减仓解读)。
import { computed, onMounted, ref } from 'vue'
import { fetchCcpmProducts, fetchCcpmRank, formatError } from '../api'
import type { CcpmProductMeta, CcpmRankResponse, CcpmRankRow } from '../types'
import HelpCollapse from '../components/HelpCollapse.vue'
import RiskDisclaimer from '../components/RiskDisclaimer.vue'
const products = ref<CcpmProductMeta[]>([])
const product = ref('IF')
const tradeDate = ref(todayIso())
const autoDate = ref(true) // ☑ 自动取最近有数据的交易日(不传 date,后端自动回溯)
const loading = ref(false)
const error = ref('')
const resp = ref<CcpmRankResponse | null>(null)
const activeInstrument = ref('')
onMounted(async () => {
try {
const body = await fetchCcpmProducts()
products.value = body.products
} catch (e) {
error.value = formatError(e)
return
}
await load()
})
function todayIso(): string {
const d = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
async function load() {
loading.value = true
error.value = ''
try {
resp.value = await fetchCcpmRank(product.value, autoDate.value ? undefined : tradeDate.value)
const insts = instruments.value
activeInstrument.value = domInstruments.value[0] ?? insts[0] ?? ''
} catch (e) {
resp.value = null
error.value = formatError(e)
} finally {
loading.value = false
}
}
const productMeta = computed(
() => products.value.find((p) => p.code === product.value) ?? null,
)
/** 全部合约(升序)。 */
const instruments = computed(() => {
if (!resp.value) return []
return [...new Set(resp.value.data.map((r) => r.instrument))].sort()
})
/** 主力合约 = 当日前 20 名合计成交量最大的合约,放第一个页签。 */
const domInstruments = computed(() => {
if (!resp.value) return []
const volByInst = new Map<string, number>()
for (const r of resp.value.data) {
volByInst.set(r.instrument, (volByInst.get(r.instrument) ?? 0) + (r.vol ?? 0))
}
const dom = [...volByInst.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]
if (!dom) return []
return [dom]
})
const isDom = (inst: string) => domInstruments.value[0] === inst
const currentRows = computed(() => {
if (!resp.value) return []
return resp.value.data
.filter((r) => r.instrument === activeInstrument.value)
.sort((a, b) => a.rank - b.rank)
})
/** 当前合约前 20 名合计(净持仓 = 多单合计 − 空单合计)。 */
const totals = computed(() => {
const rows = currentRows.value
const sum = (f: (r: CcpmRankRow) => number | null) =>
rows.reduce((acc, r) => acc + (f(r) ?? 0), 0)
const long = sum((r) => r.long_pos)
const short = sum((r) => r.short_pos)
return {
vol: sum((r) => r.vol),
volChg: sum((r) => r.vol_chg),
long,
longChg: sum((r) => r.long_chg),
short,
shortChg: sum((r) => r.short_chg),
net: long - short,
netChg: sum((r) => r.long_chg) - sum((r) => r.short_chg),
}
})
function fmt(n: number | null | undefined): string {
return n === null || n === undefined ? '—' : n.toLocaleString('zh-CN')
}
function fmtChg(n: number | null | undefined): string {
if (n === null || n === undefined || n === 0) return n === 0 ? '0' : '—'
return (n > 0 ? '+' : '') + n.toLocaleString('zh-CN')
}
function chgCls(n: number | null | undefined): string {
if (n === null || n === undefined || n === 0) return 'flat'
return n > 0 ? 'up' : 'down'
}
/** 交易日 YYYYMMDD → 展示(2026年9月2日)。 */
const dayLabel = computed(() => {
const d = resp.value?.trading_day ?? ''
if (d.length !== 8) return d
return `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}`
})
</script>
<template>
<div class="ccpm-view">
<div class="toolbar">
<h2>中金所成交持仓排名</h2>
<div class="controls">
<select v-model="product" :disabled="loading" title="选择品种">
<option v-for="p in products" :key="p.code" :value="p.code">
{{ p.code }} · {{ p.name }}
</option>
</select>
<label class="auto-date" title="勾选后忽略下方日期,自动回溯到最近一个有数据的交易日">
<input v-model="autoDate" type="checkbox" />
自动取最近交易日
</label>
<input
v-model="tradeDate"
type="date"
:disabled="loading || autoDate"
title="交易日(每个交易日收盘后约 16:15 生成数据)"
/>
<button class="primary" :disabled="loading" @click="load">
{{ loading ? '采集中' : ' 采集数据' }}
</button>
</div>
</div>
<p class="hint">
数据来自中国金融期货交易所官网成交持仓排名每个交易日收盘后约 <strong>16:15</strong>
发布按品种按合约统计<strong>成交量 / 持买单量多单/ 持卖单量空单各前 20
名期货公司会员</strong>首次采集会实时抓取官网并缓存到本地同一天再次查看不再联网
</p>
<!-- 品种信息卡跟随下拉框切换 -->
<div v-if="productMeta" class="product-card">
<div class="pc-head">
<span class="pc-code mono">{{ productMeta.code }}</span>
<span class="pc-name">{{ productMeta.name }}</span>
<span class="pc-cat">{{ productMeta.category }}</span>
</div>
<div class="pc-body">
<div class="pc-row">
<span class="pc-k">标的</span><span>{{ productMeta.underlying }}</span>
</div>
<div class="pc-row">
<span class="pc-k">合约规模</span><span>{{ productMeta.unit }}</span>
</div>
<div class="pc-row"><span class="pc-k">一句话</span><span>{{ productMeta.intro }}</span></div>
</div>
</div>
<div v-if="error" class="error-banner">
{{ error }}
<span v-if="!autoDate" class="err-tip">可勾选自动取最近交易日自动回溯</span>
</div>
<div v-if="!error && loading" class="empty">采集中正在从中金所官网拉取</div>
<template v-if="resp && !loading">
<div class="result-head">
<span class="day mono">交易日 {{ dayLabel }}</span>
<span class="dim">{{ resp.product_name }} · {{ resp.count }} 20 × {{ instruments.length }} 个合约</span>
</div>
<!-- 合约页签主力=合计成交量最大的合约排第一 -->
<div class="inst-tabs">
<button
v-for="inst in instruments"
:key="inst"
class="inst-tab mono"
:class="{ active: inst === activeInstrument }"
@click="activeInstrument = inst"
>
{{ inst }}
<span v-if="isDom(inst)" class="dom-badge">主力</span>
</button>
</div>
<!-- 20 名合计概览 -->
<div class="stat-chips">
<div class="chip">
<span class="chip-k">前20合计·多单</span>
<span class="chip-v mono">{{ fmt(totals.long) }}</span>
<span class="chip-d mono" :class="chgCls(totals.longChg)">{{ fmtChg(totals.longChg) }}</span>
</div>
<div class="chip">
<span class="chip-k">前20合计·空单</span>
<span class="chip-v mono">{{ fmt(totals.short) }}</span>
<span class="chip-d mono" :class="chgCls(totals.shortChg)">{{ fmtChg(totals.shortChg) }}</span>
</div>
<div class="chip chip-net">
<span class="chip-k">净持仓</span>
<span class="chip-v mono" :class="chgCls(totals.net)">{{ fmtChg(totals.net) }}</span>
<span class="chip-d mono" :class="chgCls(totals.netChg)">{{ fmtChg(totals.netChg) }}</span>
</div>
<div class="chip">
<span class="chip-k">当日合计成交</span>
<span class="chip-v mono">{{ fmt(totals.vol) }}</span>
<span class="chip-d mono" :class="chgCls(totals.volChg)">{{ fmtChg(totals.volChg) }}</span>
</div>
</div>
<!-- 排名表三类排名并排与官网同构 -->
<div class="table-wrap">
<table class="rank-table">
<thead>
<tr>
<th rowspan="2" class="rank-col">排名</th>
<th colspan="3" class="g g-vol">成交量排名</th>
<th colspan="3" class="g g-long">持买单量多单排名</th>
<th colspan="3" class="g g-short">持卖单量空单排名</th>
</tr>
<tr>
<th>会员简称</th><th class="num">手数</th><th class="num">增减</th>
<th>会员简称</th><th class="num">手数</th><th class="num">增减</th>
<th>会员简称</th><th class="num">手数</th><th class="num">增减</th>
</tr>
</thead>
<tbody>
<tr v-for="r in currentRows" :key="r.rank">
<td class="mono dim rank-col">{{ r.rank }}</td>
<td class="member">{{ r.vol_member ?? '—' }}</td>
<td class="num mono">{{ fmt(r.vol) }}</td>
<td class="num mono" :class="chgCls(r.vol_chg)">{{ fmtChg(r.vol_chg) }}</td>
<td class="member">{{ r.long_member ?? '—' }}</td>
<td class="num mono">{{ fmt(r.long_pos) }}</td>
<td class="num mono" :class="chgCls(r.long_chg)">{{ fmtChg(r.long_chg) }}</td>
<td class="member">{{ r.short_member ?? '—' }}</td>
<td class="num mono">{{ fmt(r.short_pos) }}</td>
<td class="num mono" :class="chgCls(r.short_chg)">{{ fmtChg(r.short_chg) }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td class="rank-col dim">合计</td>
<td class="member dim"> 20 </td>
<td class="num mono">{{ fmt(totals.vol) }}</td>
<td class="num mono" :class="chgCls(totals.volChg)">{{ fmtChg(totals.volChg) }}</td>
<td class="member dim"> 20 </td>
<td class="num mono">{{ fmt(totals.long) }}</td>
<td class="num mono" :class="chgCls(totals.longChg)">{{ fmtChg(totals.longChg) }}</td>
<td class="member dim"> 20 </td>
<td class="num mono">{{ fmt(totals.short) }}</td>
<td class="num mono" :class="chgCls(totals.shortChg)">{{ fmtChg(totals.shortChg) }}</td>
</tr>
</tfoot>
</table>
</div>
</template>
<!-- ============ 科普 ============ -->
<HelpCollapse label="这是什么数据?怎么看这张表?">
<div class="edu">
<p>
<strong>数据来源</strong>中国金融期货交易所中金所官网每个交易日收盘后发布的
成交持仓排名这里的每一行不是某个人而是<strong>一家期货公司会员名下全部客户的合计</strong>
名称后面的(代客)= 代理客户即该公司<strong>经纪业务客户</strong>的汇总
不是期货公司自己的自营盘
</p>
<p>
<strong>三组排名的含义</strong>
</p>
<ul>
<li><strong>成交量排名</strong>当天买卖成交的手数双向计数每笔成交买卖双方各记一次</li>
<li><strong>持买单量排名多单</strong>尚未平仓的<strong>买入</strong>合约数即看涨一方持有的仓位</li>
<li><strong>持卖单量排名空单</strong>尚未平仓的<strong>卖出</strong>合约数即看跌一方持有的仓位</li>
</ul>
<p>
<strong>增减</strong>= 相比上一个交易日的变化正数 = 加仓新开仓多于平仓
负数 = 减仓平仓多于新开仓例如某会员空单 800 = 其客户合计平掉了 800 手空单
</p>
<p>
<strong>注意口径</strong>只统计<strong> 20 </strong>会员通常约占全市场六到八成持仓
不是全部同一会员名下的客户里套保投机套利混在一起无法从这张表区分
</p>
</div>
</HelpCollapse>
<HelpCollapse label="品种一览:IF / IH / IC / IM / TS / TF / T / TL 是什么?">
<div class="edu">
<p>
中金所的期货分两大类<strong>股指期货</strong>跟踪股票指数用来交易大盘涨跌
<strong>国债期货</strong>跟踪利率用来交易利率涨跌合约代码后四位是到期月
IF2609 = 2026 9 月到期的沪深300股指期货
</p>
<table class="edu-table">
<thead>
<tr><th>代码</th><th>名称</th><th>跟踪什么</th><th>1 手规模</th><th>代表市场哪一块</th></tr>
</thead>
<tbody>
<tr v-for="p in products" :key="p.code">
<td class="mono">{{ p.code }}</td>
<td>{{ p.name }}</td>
<td>{{ p.underlying }}</td>
<td>{{ p.unit }}</td>
<td>{{ p.intro }}</td>
</tr>
</tbody>
</table>
<p>
<strong>国债期货补充</strong>它不跟踪某只指数标的是名义标准国债
本质是<strong>利率期货</strong>价格与市场利率<strong>反向</strong>
国债期货涨价 市场预期利率下行债券牛市跌价 预期利率上行
四个品种对应 2 / 5 / 10 / 30 年期限期限越长对利率越敏感TL 波动最大
</p>
</div>
</HelpCollapse>
<HelpCollapse label="新手科普:多单、空单、加减仓怎么看?是对冲还是纯多纯空?">
<div class="edu">
<p><strong>先理解期货</strong>期货是约定未来按某个价格买卖的合约今天买入 = 认为未来会涨<strong>多头</strong>今天卖出 = 认为未来会跌<strong>空头</strong>不持有合约也可以先卖这是期货和股票最大的不同</p>
<p><strong>多单持买单量</strong>已经买入还没平仓的合约持有的人分两种 <strong>看涨投机</strong>赌指数上涨赚差价 <strong>多头套保</strong>未来要买入一篮子股票先买期货锁定成本</p>
<p><strong>空单持卖单量</strong>已经卖出还没平仓的合约持有的人也分两种 <strong>看跌投机</strong>赌指数下跌 <strong>套保空单</strong>最常见机构手里已经拿着股票现货卖出股指期货来<strong>对冲大盘下跌风险</strong>量化中性策略就是典型买一篮子股票 + 卖空等值股指期货赚选股超额收益剥离大盘涨跌</p>
<p>
<strong> 最重要的一点这张表看不出对冲还是纯做空</strong>
排名只披露期货公司客户合计持仓不区分目的股指期货的空单大头通常是机构套保盘
<strong>空单多 / 空单增加 看空市场</strong>很多情况下反而说明机构持有大量现货股票
把空单直接读成利空是新手最常见的误读
</p>
<p><strong>加仓 / 减仓增减列</strong>正数 = 加仓负数 = 减仓常见组合的含义仅供参考非预测</p>
<ul>
<li><strong>多单增加</strong>有人新进场做多或多头加码看涨意愿增强</li>
<li><strong>多单减少</strong>多头获利了结或止损离场</li>
<li><strong>空单增加</strong>新空头进场<strong></strong>机构加套保可能只是现货仓位变大了</li>
<li><strong>空单减少</strong>空头回补看跌者买回平仓<strong></strong>套保盘解除机构卖出股票后不再需要对冲</li>
</ul>
<p><strong>净持仓</strong>本页顶部净持仓= 20 名多单合计减空单合计粗略衡量头部席位的多空力量对比 = 偏多 = 偏空但它只覆盖前 20 且混合了各类目的的仓位只能作为情绪参考<strong>不能单独当作涨跌预测</strong></p>
<p><strong>为什么全市场多空永远相等</strong>期货是零和合约每有一张多单必然对应一张空单你买到的合约就是别人卖出的所以看全市场谁多谁空没有意义排名表真正告诉你的是<strong>仓位集中在哪些期货公司的客户手里它们在加码还是撤退</strong></p>
</div>
</HelpCollapse>
<RiskDisclaimer prominent>
<strong> 风险提示与免责声明</strong>
<p>
本页面数据来自中金所官网公开披露仅供量化研究与学习参考持仓排名仅反映前
20 名会员客户的仓位分布不区分套保/投机/套利目的<strong>不构成任何投资建议
不能预测市场涨跌</strong>期货交易带杠杆亏损可能超过本金入市需谨慎
据此操作风险自负
</p>
</RiskDisclaimer>
</div>
</template>
<style scoped>
.ccpm-view {
height: 100%;
overflow-y: auto;
padding: 14px 16px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.toolbar h2 {
font-size: 16px;
}
.controls {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.controls select,
.controls input[type='date'] {
padding: 6px 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
}
.controls select:focus,
.controls input[type='date']:focus {
outline: none;
border-color: var(--accent);
}
.controls input[type='date']:disabled {
opacity: 0.45;
}
.auto-date {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
cursor: pointer;
user-select: none;
}
.hint {
margin: 8px 0 12px;
font-size: 12px;
color: var(--text-dim);
line-height: 1.7;
}
/* 品种信息卡 */
.product-card {
margin-bottom: 12px;
padding: 10px 14px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.pc-head {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 6px;
}
.pc-code {
font-size: 15px;
font-weight: 700;
color: var(--accent);
}
.pc-name {
font-size: 14px;
font-weight: 600;
}
.pc-cat {
font-size: 11px;
color: var(--text-dim);
border: 1px solid var(--border);
border-radius: 999px;
padding: 1px 8px;
}
.pc-row {
display: flex;
gap: 10px;
font-size: 12px;
line-height: 1.8;
color: var(--text-muted);
}
.pc-k {
flex-shrink: 0;
width: 60px;
color: var(--text-dim);
}
.error-banner {
padding: 8px 12px;
background: rgba(244, 67, 54, 0.1);
border-radius: var(--radius);
font-size: 12px;
color: var(--red, #f44336);
}
.err-tip {
color: var(--text-dim);
}
.empty {
color: var(--text-dim);
padding: 40px;
text-align: center;
font-size: 13px;
}
.dim {
color: var(--text-dim);
}
.mono {
font-family: var(--font-mono);
}
.result-head {
display: flex;
align-items: baseline;
gap: 10px;
font-size: 13px;
margin: 6px 0 8px;
}
.day {
font-weight: 600;
}
/* 合约页签 */
.inst-tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.inst-tab {
font-size: 13px;
padding: 4px 12px;
border-radius: 999px;
}
.inst-tab.active {
border-color: var(--accent);
color: var(--accent);
background: rgba(74, 158, 255, 0.1);
}
.dom-badge {
margin-left: 4px;
font-size: 10px;
color: var(--warn);
}
/* 合计概览 chips */
.stat-chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.chip {
display: flex;
align-items: baseline;
gap: 8px;
padding: 6px 12px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 12px;
}
.chip-net {
border-color: rgba(240, 160, 32, 0.45);
}
.chip-k {
color: var(--text-dim);
}
.chip-v {
font-size: 14px;
font-weight: 600;
}
.chip-d {
font-size: 11px;
}
/* 排名表 */
.table-wrap {
overflow-x: auto;
margin-bottom: 6px;
}
.rank-table {
width: 100%;
min-width: 860px;
border-collapse: collapse;
font-size: 12.5px;
}
.rank-table th,
.rank-table td {
padding: 6px 8px;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: middle;
white-space: nowrap;
}
.rank-table thead th {
color: var(--text-dim);
font-size: 11.5px;
font-weight: 600;
border-bottom: 1px solid var(--border);
}
.rank-table .g {
text-align: center;
font-size: 12px;
color: var(--text-muted);
}
.rank-table .g-vol {
background: rgba(74, 158, 255, 0.06);
}
.rank-table .g-long {
background: rgba(239, 65, 70, 0.06);
}
.rank-table .g-short {
background: rgba(24, 160, 88, 0.06);
}
.rank-col {
width: 40px;
text-align: center !important;
}
.member {
min-width: 110px;
}
.num {
text-align: right !important;
font-size: 12px;
}
tfoot td {
border-top: 1px solid var(--border);
background: var(--bg-panel);
font-weight: 600;
}
.up {
color: var(--up);
}
.down {
color: var(--down);
}
.flat {
color: var(--text-dim);
}
/* 科普 */
.edu {
font-size: 12.5px;
color: var(--text-muted);
line-height: 1.9;
}
.edu p {
margin: 6px 0;
}
.edu ul {
margin: 6px 0 6px 18px;
}
.edu li {
margin: 3px 0;
}
.edu strong {
color: var(--text);
}
.edu-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
margin: 8px 0;
}
.edu-table th,
.edu-table td {
padding: 6px 8px;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
line-height: 1.6;
}
.edu-table th {
color: var(--text-dim);
font-weight: 600;
white-space: nowrap;
}
</style>