mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 16:54:20 +08:00
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:
@@ -37,3 +37,6 @@ audit-report-*.html
|
||||
web-ui/e2e/.results/
|
||||
web-ui/test-results/
|
||||
web-ui/playwright-report/
|
||||
|
||||
# ZCode agent 会话目录(本地,不入库)
|
||||
.zcode/
|
||||
|
||||
@@ -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 零真实网络)。
|
||||
|
||||
@@ -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 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 持久化),勾选多个策略做资金分仓组合回测看综合表现,全程零代码。
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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 长表结构(每条记录一个 ``<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
|
||||
@@ -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()]
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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 = """<?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_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(
|
||||
"<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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: '/' },
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user