Files
easy_tdx_max/tests/unit/test_ccpm.py
T
GitHub 99397a12e3 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
2026-09-02 20:54:59 +08:00

393 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""中金所成交持仓排名(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