"""中金所成交持仓排名(ccpm)离线测试 —— mock HTTP,零网络依赖。 覆盖:XML 解析(长表→宽表对齐)、按日缓存读写、非交易日 302 语义、 日期/品种归一化、latest_rank 自动回溯、品种元数据完整性、 Web 路由(/ccpm/products、/ccpm/rank)与 CLI 命令。 """ from __future__ import annotations import json from datetime import date import pytest # --------------------------------------------------------------------------- # 测试夹具:最小 positionRank XML(1 个合约 × 3 类排名 × 前 2 名) # --------------------------------------------------------------------------- _SAMPLE_XML = """ IF260920260902 01 甲期货(代客)10010 0001IF IF260920260902 02 乙期货(代客)80-5 0002IF IF260920260902 11 丙期货(代客)220-7 0003IF IF260920260902 12 甲期货(代客)1800 0001IF IF260920260902 21 乙期货(代客)1503 0002IF IF260920260902 22 丙期货(代客)90-2 0003IF """ @pytest.fixture() def isolated_config(tmp_path, monkeypatch): """缓存目录隔离到 tmp_path(EASY_TDX_CONFIG_DIR 约定)。""" monkeypatch.setenv("EASY_TDX_CONFIG_DIR", str(tmp_path)) return tmp_path def _mock_fetch(monkeypatch, sample: str = _SAMPLE_XML): """把 _fetch_xml 替换为返回固定 XML,并记录调用 URL。""" from easy_tdx.ccpm import client as ccpm_client calls: list[str] = [] def fake(url: str, timeout: float) -> str: calls.append(url) return sample monkeypatch.setattr(ccpm_client, "_fetch_xml", fake) return calls # --------------------------------------------------------------------------- # 领域异常与导出 # --------------------------------------------------------------------------- def test_error_hierarchy() -> None: """CcpmError/CcpmNoDataError 必须继承 TdxError(全局 except 覆盖)。""" from easy_tdx.ccpm import CcpmError, CcpmNoDataError from easy_tdx.exceptions import TdxError assert issubclass(CcpmError, TdxError) assert issubclass(CcpmNoDataError, CcpmError) def test_public_exports() -> None: from easy_tdx import ccpm for name in ("CcpmClient", "CcpmError", "CcpmNoDataError", "PRODUCTS", "list_products"): assert hasattr(ccpm, name) # --------------------------------------------------------------------------- # 品种元数据 # --------------------------------------------------------------------------- def test_products_meta_complete() -> None: """8 个品种:股指 4 + 国债 4,字段非空,股指带指数代码。""" from easy_tdx.ccpm import PRODUCTS, list_products assert list(PRODUCTS) == ["IF", "IH", "IC", "IM", "TS", "TF", "T", "TL"] metas = list_products() assert len(metas) == 8 for m in metas: for field in ("code", "name", "category", "underlying", "unit", "intro"): assert m[field], f"{m['code']} 缺少 {field}" assert sum(1 for m in metas if m["category"] == "股指期货") == 4 assert sum(1 for m in metas if m["category"] == "国债期货") == 4 # 股指期货必须给出对应指数代码,国债期货为空串 assert PRODUCTS["IF"].underlying_code == "000300" assert PRODUCTS["TL"].underlying_code == "" def test_normalize_product_case_insensitive() -> None: from easy_tdx.ccpm import normalize_product assert normalize_product("if").code == "IF" assert normalize_product(" Tl ").code == "TL" with pytest.raises(ValueError, match="未知品种"): normalize_product("XX") # --------------------------------------------------------------------------- # XML 解析:长表 → 宽表对齐 # --------------------------------------------------------------------------- def test_parse_xml_wide_alignment() -> None: from easy_tdx.ccpm import WIDE_COLUMNS, parse_xml rows = parse_xml(_SAMPLE_XML) assert len(rows) == 2 # 1 合约 × rank 1..2 assert set(rows[0]) == set(WIDE_COLUMNS) r1 = rows[0] assert r1["instrument"] == "IF2609" assert r1["trading_day"] == "20260902" assert r1["product"] == "IF" assert r1["rank"] == 1 # 三类排名各自独立取自对应 datatypeid assert r1["vol_member"] == "甲期货(代客)" and r1["vol"] == 100 and r1["vol_chg"] == 10 assert r1["long_member"] == "丙期货(代客)" and r1["long_pos"] == 220 and r1["long_chg"] == -7 assert r1["short_member"] == "乙期货(代客)" and r1["short_pos"] == 150 and r1["short_chg"] == 3 r2 = rows[1] assert r2["rank"] == 2 and r2["long_chg"] == 0 and r2["short_chg"] == -2 def test_parse_xml_missing_cell_fills_none() -> None: """某类型缺某排名时对应单元格为 None,不丢行、不错位。""" from easy_tdx.ccpm import parse_xml # 只有 datatypeid=0 的 rank1,其余类型缺失 partial = _SAMPLE_XML.replace( "1", "9" ).replace("2", "9") rows = parse_xml(partial) assert rows assert rows[0]["vol"] == 100 assert rows[0]["long_member"] is None and rows[0]["long_pos"] is None assert rows[0]["short_member"] is None and rows[0]["short_pos"] is None def test_parse_xml_error_page_raises() -> None: from easy_tdx.ccpm import CcpmError, parse_xml with pytest.raises(CcpmError, match="XML 解析失败"): parse_xml("404 page,非 XML 内容") def test_parse_xml_structure_change_raises() -> None: """XML 合法但 0 个 节点且存在其他子结构:抛 CcpmError(官网改版信号)。 回归:旧实现静默返回空表——改版后 CLI/Web 层展示"无数据"而非报错, 改版长期无人察觉。正常发布日必有 ;真实无数据日走 302。 """ from easy_tdx.ccpm import CcpmError, parse_xml changed = ( '' "IF2609" ) with pytest.raises(CcpmError, match="结构可能已变更"): parse_xml(changed) def test_parse_xml_empty_root_is_no_data() -> None: """空 (无任何子节点):按当日无数据处理,返回空表。""" from easy_tdx.ccpm import parse_xml assert parse_xml('') == [] def test_get_rank_structure_change_raises(isolated_config, monkeypatch) -> None: """结构变更经 get_rank 透传为 CcpmError(不缓存、不返回空表伪装成功)。""" from easy_tdx.ccpm import CcpmClient, CcpmError _mock_fetch(monkeypatch, sample="1") with pytest.raises(CcpmError, match="结构可能已变更"): CcpmClient().get_rank("IF", "2026-09-02") # --------------------------------------------------------------------------- # 日期归一化 # --------------------------------------------------------------------------- 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