mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 13:24:18 +08:00
对 v1.21→v1.32.5 的 249 文件 4.2 万行改动做六路专项审查,本轮落地全部发现: 回测正确性:组合收益 fillna(0) 虚增、轮动停牌日过期价成交、单标的 WF 逐窗指标 被预热区稀释(三件套均带先红后绿回归);worst_drawdown 方向、grading 容错、 组合体检品种费率、寻优端点费率透传。 安全:LLM api_url 仅 http/https 且禁 userinfo(封死 file:// 读取与 Key 外送链)、 错误响应不回显原始 body、响应体 2MB 上限、配置原子写、坏配置字段级防御。 数据:涨跌停价整数分币舍入(67/318/90 个价位错 1 分漏判清零)、交易时段/采样/ provisional 统一沪时区、warehouse 增量缺口自动全量重拉、provisional 定点转正、 baostock 真故障抛错 + W/M 去 tradestatus(实测服务端报错,周月兜底此前从未工作) + 指数 vol 股→手(实测锚定)、ccpm 结构变更抛错。 Web API:缓存键补 count/vipdoc、NaN 清洗先于缓存、count>800 分页取全量、 submit 透传真实状态、pending 不再被淘汰成幽灵、watchlist/server 入参约束。 公式:FILTER 去副作用、0-1 值域误判收严、递归深度上限、REF 负移位显式禁止。 前端:4 处请求竞态序号守卫、Sparkline viewBox、北交所 market=2 映射、 空数据缓存死角、AI 弹窗卸载中止轮询、量能/资金日历口径修正。 CLI/CI:warehouse sync 失败 exit 1、参数校验干净报错、release 真实发布 SHA256、 CI 超时与缓存、spec 补 baostock 前提。 约 60 条回归测试先红后绿;pytest 1820 全过,ruff/mypy/vue-tsc/node --test 全绿。
329 lines
12 KiB
Python
329 lines
12 KiB
Python
"""中金所成交持仓排名(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"),
|
||
}
|
||
|
||
if not cells:
|
||
if len(root) > 0:
|
||
# XML 合法但 0 个 <data> 数据节点,且根元素下存在其他子结构——
|
||
# 官网模板/字段改版的典型形态(正常发布日必有 <data>;真实
|
||
# 「无数据日」走 302 → CcpmNoDataError)。静默返回空表会让
|
||
# 改版长期伪装成"无数据",这里显式报错。
|
||
tags = ",".join(sorted({child.tag for child in root}))[:200]
|
||
raise CcpmError(
|
||
f"XML 解析出 0 条 <data> 数据节点(根元素 <{root.tag}>,"
|
||
f"子节点: {tags})——中金所页面结构可能已变更"
|
||
)
|
||
# 根元素无任何子节点(如空 <positionRank/>):按当日无数据处理
|
||
return []
|
||
|
||
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
|