feat(cninfo): 新增巨潮公告检索 — 三层接入(API/CLI/Web),独立数据源

This commit is contained in:
Justin Gu
2026-06-14 23:29:19 +08:00
parent a12c2122fc
commit 4b439920fc
11 changed files with 794 additions and 1 deletions
+23
View File
@@ -132,6 +132,16 @@ easy-tdx server-info --table
easy-tdx symbol-info SZ 000001 --table
```
### 公告检索(巨潮资讯网)
```bash
easy-tdx announcement 688017 # 默认 30 条,JSON 输出
easy-tdx announcement 600519 --count 50 --page 2 # 翻页
easy-tdx announcement 000001 --table # 表格输出
```
> 独立数据源(巨潮资讯网),无需连接 TDX 行情服务器即可使用。
### 技术指标
```bash
@@ -855,6 +865,9 @@ curl "http://localhost:8000/api/v1/mac/symbol-info?market=SZ&code=000001"
# 服务器交易时段信息
curl "http://localhost:8000/api/v1/mac/server-info"
# ── 公告检索(巨潮资讯网,独立数据源)──
curl "http://localhost:8000/api/v1/announcements?code=688017&count=30&page=1"
# ── 排行 / 竞价 / 异动 ──
# 全 A 涨幅排行前 20
curl "http://localhost:8000/api/v1/mac/quote-list?category=A&count=20&sort_type=CHANGE_PCT"
@@ -1515,6 +1528,16 @@ ruff format --check src/ tests/ # format check
## Changelog
### 1.13.0 (2026-06-14)
**新增巨潮公告检索** — 三层接入(编程 API / CLI / Web API),独立数据源,无需连接 TDX 行情服务器。
- 新模块 `easy_tdx.cninfo``CninfoClient().get_announcements(code, count=, page=)` 返回 `DataFrame[title, type, date, url]`
- CLI`easy-tdx announcement 688017 [--count N --page N --table]`
- Web`GET /api/v1/announcements?code=&count=&page=`
- 标准库 urllib 实现,零新依赖
- 沿用 #19 修复的 orgId 动态映射 + 三段硬编码 fallback(保证 601xxx 段可查)
### 1.12.0 (2026-06-14)
**新增 4 个技术指标(30 → 34)** — 按"语义空白"补齐三类现有指标库缺失的维度:止损位、机构成本价、趋势启动时机。均为纯 numpy 实现,零新依赖。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
version = "1.12.0"
version = "1.13.0"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md"
requires-python = ">=3.10"
+2
View File
@@ -7,6 +7,7 @@ import click
from ..backtest.cli import backtest, portfolio
from ..screen.cli import screen
from .cmd_admin import ping, version
from .cmd_announcement import announcement
from .cmd_auction import auction
from .cmd_board import (
belong_board,
@@ -59,6 +60,7 @@ def cli() -> None:
cli.add_command(ping)
cli.add_command(version)
cli.add_command(announcement)
cli.add_command(kline)
cli.add_command(quote)
cli.add_command(quote_list)
+36
View File
@@ -0,0 +1,36 @@
"""公告检索命令(巨潮资讯网数据源,无需 TDX 服务器)。"""
from __future__ import annotations
import click
@click.command("announcement")
@click.argument("code")
@click.option("--count", default=30, type=int, help="每页数量")
@click.option("--page", default=1, type=int, help="页码(1 起始)")
@click.option("--table", "use_table", is_flag=True, help="表格输出")
@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json")
def announcement(code: str, count: int, page: int, use_table: bool, output_fmt: str) -> None:
"""检索公司公告(巨潮资讯网,独立数据源,无需连接 TDX)。
\b
示例:
easy-tdx announcement 688017
easy-tdx announcement 600519 --count 50 --page 2
easy-tdx announcement 000001 --table
"""
from ..cninfo import CninfoClient, CninfoError
from .output import print_error, print_output
fmt = "table" if use_table else output_fmt
client = CninfoClient()
try:
df = client.get_announcements(code, count=count, page=page)
except CninfoError as e:
print_error(str(e))
raise SystemExit(1) from e
print_output(df, fmt)
+18
View File
@@ -0,0 +1,18 @@
"""巨潮资讯网(cninfo)公告检索 —— 独立于 TDX 协议的 HTTP 数据源。
零额外依赖(标准库 urllib),无需连接 TDX 服务器即可使用。
用法::
from easy_tdx.cninfo import CninfoClient
df = CninfoClient().get_announcements("688017", count=30)
# → DataFrame[title, type, date, url]
"""
from __future__ import annotations
from .client import CninfoClient
from .models import Announcement, CninfoError
__all__ = ["CninfoClient", "Announcement", "CninfoError"]
+193
View File
@@ -0,0 +1,193 @@
"""巨潮资讯网(cninfo)公告检索客户端。
独立于 TDX 协议的 HTTP 数据源(标准库 urllib,零额外依赖)。
公开方法返回 ``pd.DataFrame``,遵循项目 ``get_*`` 约定。
参考实现说明(来自 #19 修复):
- 巨潮 ``orgId`` 并非统一的 ``gssx0{code}`` 格式(如 601318→9900002221、
601398→jjxt0000019、688017→9900041602),硬编码会导致大量股票
(尤其 601xxx 段)返回 ``totalAnnouncement=0``、查不到公告。
- 优先动态查官方映射表,查不到再回退硬编码规则。
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Any
from urllib import parse
from urllib import request as urlrequest
import pandas as pd
from .models import Announcement, CninfoError
logger = logging.getLogger(__name__)
# 巨潮公告检索请求固定头(Referer/Origin 必填,否则被反爬拦截)
_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
_STOCK_MAP_URL = "http://www.cninfo.com.cn/new/data/szse_stock.json"
_QUERY_URL = "https://www.cninfo.com.cn/new/hisAnnouncement/query"
_DETAIL_URL = "https://www.cninfo.com.cn/new/disclosure/detail?annoId="
# 模块级 orgId 映射缓存:首次拉取后全程复用(Cpython dict 读写原子,
# 并发下最坏多发一次请求,可接受)
_ORGID_MAP: dict[str, str] = {}
def _ts_to_date(ts: Any) -> str:
"""巨潮 ``announcementTime`` 返回 Unix 毫秒整数,转 ``YYYY-MM-DD``。"""
if isinstance(ts, (int, float)):
return datetime.fromtimestamp(ts / 1000).strftime("%Y-%m-%d")
return str(ts)[:10] if ts else ""
def _http_get_json(url: str, timeout: float = 15.0) -> Any:
"""GET JSONstdlib urllibmonkeypatch 点)。"""
req = urlrequest.Request(url, headers={"User-Agent": _UA})
with urlrequest.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def _http_post_form(url: str, payload: dict[str, str], timeout: float = 15.0) -> Any:
"""POST x-www-form-urlencoded,返回 JSONstdlib urllibmonkeypatch 点)。"""
data = parse.urlencode(payload).encode("utf-8")
req = urlrequest.Request(
url,
data=data,
headers={
"User-Agent": _UA,
"Content-Type": "application/x-www-form-urlencoded",
"Referer": "https://www.cninfo.com.cn/new/disclosure",
"Origin": "https://www.cninfo.com.cn",
},
method="POST",
)
with urlrequest.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
class CninfoClient:
"""巨潮公告检索客户端(无状态 HTTP,无需 connect/close)。
用法::
from easy_tdx.cninfo import CninfoClient
df = CninfoClient().get_announcements("688017", count=30)
# → DataFrame[title, type, date, url]
"""
def __init__(self, *, timeout: float = 15.0) -> None:
self.timeout = timeout
# ------------------------------------------------------------------
# orgId 解析(#19 修复逻辑:动态表 + 三段硬编码 fallback)
# ------------------------------------------------------------------
def _fetch_stock_map(self) -> dict[str, str]:
"""拉取官方 szse_stock.json,构建 code→orgId 映射。"""
try:
d = _http_get_json(_STOCK_MAP_URL, timeout=self.timeout)
stock_list = d.get("stockList", []) if isinstance(d, dict) else []
return {s["code"]: s["orgId"] for s in stock_list}
except Exception as e: # noqa: BLE001 — 拉取失败需 graceful fallback
logger.warning("巨潮 orgId 映射表拉取失败,回退硬编码规则: %s", e)
return {}
def _resolve_orgid(self, code: str) -> str:
"""查股票真实 orgId,动态表优先,查不到回退硬编码规则。"""
global _ORGID_MAP
if not _ORGID_MAP:
fetched = self._fetch_stock_map()
# 只在确实取到数据时写入缓存;空结果保留以便下次重试
if fetched:
_ORGID_MAP.update(fetched)
org = _ORGID_MAP.get(code)
if org:
return org
# fallback:老格式(仅部分老股票如 600519/600036 适用)
if code.startswith("6"):
return f"gssh0{code}"
if code.startswith("8") or code.startswith("4"):
return f"gsbj0{code}"
return f"gssz0{code}"
# ------------------------------------------------------------------
# 公开 API
# ------------------------------------------------------------------
def get_announcements(
self,
code: str,
*,
count: int = 30,
page: int = 1,
) -> pd.DataFrame:
"""检索指定股票的公告列表。
Args:
code: 6 位股票代码(不含市场前缀),如 ``688017``。
count: 每页数量(即 pageSize)。
page: 页码(1 起始)。
Returns:
``DataFrame[title, type, date, url]``,按服务器返回顺序(最新在前)。
无结果时返回空 DataFrame(含列名)。
"""
rows = self._query_announcements(code, count=count, page=page)
if not rows:
return pd.DataFrame(columns=["title", "type", "date", "url"])
return pd.DataFrame([r.__dict__ for r in rows])
def _query_announcements(self, code: str, *, count: int, page: int) -> list[Announcement]:
"""POST 公告检索接口,解析为 Announcement 列表。
整个 HTTP + 解析过程统一捕获异常并转为 ``CninfoError``,避免
``announcementTime`` 等字段畸形时 ``_ts_to_date`` 抛出未捕获异常。
"""
org_id = self._resolve_orgid(code)
payload = {
"stock": f"{code},{org_id}",
"tabName": "fulltext",
"pageSize": str(count),
"pageNum": str(page),
"column": "",
"category": "",
"plate": "",
"seDate": "",
"searchkey": "",
"secid": "",
"sortName": "",
"sortType": "",
"isHLtitle": "true",
}
try:
d = _http_post_form(_QUERY_URL, payload, timeout=self.timeout)
items = d.get("announcements", []) if isinstance(d, dict) else None
if not items:
return []
result: list[Announcement] = []
for item in items:
if not isinstance(item, dict):
continue
anno_id = item.get("announcementId", "")
result.append(
Announcement(
title=item.get("announcementTitle", ""),
type=item.get("announcementTypeName", ""),
date=_ts_to_date(item.get("announcementTime")),
url=f"{_DETAIL_URL}{anno_id}",
)
)
return result
except CninfoError:
raise
except Exception as e: # noqa: BLE001 — HTTP 失败 / JSON 解析 / 日期转换统一转领域异常
raise CninfoError(f"巨潮公告检索失败: {e}") from e
+24
View File
@@ -0,0 +1,24 @@
"""巨潮资讯网(cninfo)数据模型。"""
from __future__ import annotations
from dataclasses import dataclass
from easy_tdx.exceptions import TdxError
@dataclass(frozen=True)
class Announcement:
"""单条公告记录。
巨潮公告检索接口返回的标准化结构。
"""
title: str
type: str
date: str # YYYY-MM-DD
url: str
class CninfoError(TdxError):
"""巨潮数据请求或解析失败。"""
+3
View File
@@ -140,6 +140,7 @@ def _create_app(
register_exception_handlers(app)
# Mount routers
from easy_tdx.web.routers.announcement import router as announcement_router
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
@@ -166,5 +167,7 @@ def _create_app(
app.include_router(ex_market_router, prefix="/api/v1")
# 技术指标路由
app.include_router(indicator_router, prefix="/api/v1")
# 公告检索路由(巨潮资讯网,独立数据源)
app.include_router(announcement_router, prefix="/api/v1")
return app
+37
View File
@@ -0,0 +1,37 @@
"""公告检索路由(巨潮资讯网,独立数据源,不依赖 TDX 服务器)。"""
from __future__ import annotations
import asyncio
from fastapi import APIRouter, Query
from easy_tdx.web.schemas import DataFrameResponse
router = APIRouter(tags=["announcement"])
@router.get("/announcements", response_model=DataFrameResponse)
async def announcements(
code: str = Query(..., min_length=6, max_length=6, description="6位股票代码"),
count: int = Query(30, ge=1, le=100, description="每页数量"),
page: int = Query(1, ge=1, description="页码(1 起始)"),
) -> DataFrameResponse:
"""检索公司公告列表(巨潮资讯网,独立于 TDX 行情服务器)。
TDX 行情不可用时本接口仍可调用。
"""
from easy_tdx.cninfo import CninfoClient, CninfoError
client = CninfoClient()
def _fetch() -> DataFrameResponse:
df = client.get_announcements(code, count=count, page=page)
return DataFrameResponse.from_dataframe(df)
try:
return await asyncio.to_thread(_fetch)
except CninfoError as e:
from fastapi import HTTPException
raise HTTPException(status_code=503, detail=str(e)) from e
+456
View File
@@ -0,0 +1,456 @@
"""巨潮(cninfo)模块离线测试 —— mock HTTP,零网络依赖。
覆盖:日期转换、orgId 解析(动态表/三段 fallback)、公告解析、分页、
错误转换、模块导出。
"""
from __future__ import annotations
import json
from typing import Any
import pandas as pd
import pytest
# ---------------------------------------------------------------------------
# 导出
# ---------------------------------------------------------------------------
def test_public_exports() -> None:
"""模块应导出 CninfoClient / Announcement / CninfoError。"""
from easy_tdx import cninfo
assert hasattr(cninfo, "CninfoClient")
assert hasattr(cninfo, "Announcement")
assert hasattr(cninfo, "CninfoError")
def test_announcement_is_frozen_dataclass() -> None:
"""Announcement 应为 frozen dataclass,含 title/type/date/url。"""
from easy_tdx.cninfo import Announcement
a = Announcement(title="t", type="ty", date="2026-06-14", url="http://x")
assert a.title == "t"
assert a.type == "ty"
assert a.date == "2026-06-14"
assert a.url == "http://x"
# frozen
with pytest.raises(Exception):
a.title = "mutated" # type: ignore[misc]
def test_cninfo_error_is_exception() -> None:
from easy_tdx.cninfo import CninfoError
from easy_tdx.exceptions import TdxError
assert issubclass(CninfoError, Exception)
# 回归 #1CninfoError 必须继承 TdxError,保证全局 except TdxError 覆盖
assert issubclass(CninfoError, TdxError)
# ---------------------------------------------------------------------------
# 日期转换
# ---------------------------------------------------------------------------
def test_ts_to_date_from_millis() -> None:
"""Unix 毫秒整数应转为 YYYY-MM-DD。"""
from easy_tdx.cninfo.client import _ts_to_date
# 1718323200000 ms = 2024-06-14 00:00:00 UTC ≈ 当地日期
assert _ts_to_date(1718323200000) # 非空字符串,长度 10
assert len(_ts_to_date(1718323200000)) == 10
def test_ts_to_date_from_string() -> None:
"""字符串输入应取前 10 字符。"""
from easy_tdx.cninfo.client import _ts_to_date
assert _ts_to_date("2026-06-14T08:00:00") == "2026-06-14"
def test_ts_to_date_empty() -> None:
from easy_tdx.cninfo.client import _ts_to_date
assert _ts_to_date("") == ""
assert _ts_to_date(None) == ""
# ---------------------------------------------------------------------------
# orgId 解析(动态表 + 三段 fallback)
# ---------------------------------------------------------------------------
@pytest.fixture
def reset_orgid_cache() -> Any:
"""每个测试前后清空 orgId 缓存,保证隔离。"""
import easy_tdx.cninfo.client as mod
mod._ORGID_MAP.clear()
yield
mod._ORGID_MAP.clear()
def _patch_stock_map(monkeypatch: pytest.MonkeyPatch, mapping: dict[str, str]) -> None:
"""让 _fetch_stock_map 返回给定映射(不触网)。"""
monkeypatch.setattr(
"easy_tdx.cninfo.client._http_get_json",
lambda url, timeout=15.0: {
"stockList": [{"code": c, "orgId": o} for c, o in mapping.items()]
},
)
def test_resolve_orgid_from_dynamic_map(
monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any
) -> None:
"""动态表命中应返回表中 orgId。"""
from easy_tdx.cninfo import CninfoClient
_patch_stock_map(monkeypatch, {"688017": "9900041602", "601318": "9900002221"})
client = CninfoClient()
assert client._resolve_orgid("688017") == "9900041602"
assert client._resolve_orgid("601318") == "9900002221"
def test_resolve_orgid_fallback_6_prefix(
monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any
) -> None:
"""动态表无此 code 且 6 开头 → gssh0{code}"""
from easy_tdx.cninfo import CninfoClient
_patch_stock_map(monkeypatch, {})
client = CninfoClient()
assert client._resolve_orgid("600519") == "gssh0600519"
def test_resolve_orgid_fallback_8_prefix(
monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any
) -> None:
"""北交所 8/4 开头 → gsbj0{code}"""
from easy_tdx.cninfo import CninfoClient
_patch_stock_map(monkeypatch, {})
client = CninfoClient()
assert client._resolve_orgid("830799") == "gsbj0830799"
assert client._resolve_orgid("430047") == "gsbj0430047"
def test_resolve_orgid_fallback_sz_default(
monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any
) -> None:
"""其他前缀(深圳)→ gssz0{code}"""
from easy_tdx.cninfo import CninfoClient
_patch_stock_map(monkeypatch, {})
client = CninfoClient()
assert client._resolve_orgid("000001") == "gssz0000001"
def test_resolve_orgid_empty_map_does_not_cache(
monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any
) -> None:
"""映射表为空时不写入缓存,下次仍会重试(避免永久 fallback)。"""
import easy_tdx.cninfo.client as mod
from easy_tdx.cninfo import CninfoClient
_patch_stock_map(monkeypatch, {})
client = CninfoClient()
client._resolve_orgid("600519")
assert mod._ORGID_MAP == {}
def test_resolve_orgid_fetch_failure_fallback(
monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any
) -> None:
"""映射表拉取异常应 graceful fallback 到硬编码规则,不抛错。"""
from easy_tdx.cninfo import CninfoClient
def _boom(url: str, timeout: float = 15.0) -> Any:
raise OSError("network down")
monkeypatch.setattr("easy_tdx.cninfo.client._http_get_json", _boom)
client = CninfoClient()
# 不抛错,回退 SH 规则
assert client._resolve_orgid("600519") == "gssh0600519"
def test_resolve_orgid_cache_reused(
monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any
) -> None:
"""第二次调用不应再次拉取映射表(缓存命中)。"""
call_count = {"n": 0}
def _fake(url: str, timeout: float = 15.0) -> Any:
call_count["n"] += 1
return {"stockList": [{"code": "688017", "orgId": "9900041602"}]}
monkeypatch.setattr("easy_tdx.cninfo.client._http_get_json", _fake)
from easy_tdx.cninfo import CninfoClient
client = CninfoClient()
client._resolve_orgid("688017")
client._resolve_orgid("688017")
assert call_count["n"] == 1
# ---------------------------------------------------------------------------
# 公告查询与解析
# ---------------------------------------------------------------------------
_QUERY_RESPONSE: dict[str, Any] = {
"announcements": [
{
"announcementTitle": "关于召开2025年年度股东大会的通知",
"announcementTypeName": "股东大会",
"announcementTime": 1749859200000,
"announcementId": "abc123",
},
{
"announcementTitle": "2024年年度报告",
"announcementTypeName": "定期报告",
"announcementTime": 1740614400000,
"announcementId": "def456",
},
],
"totalAnnouncement": 2,
}
def test_get_announcements_returns_dataframe(
monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any
) -> None:
"""应返回 DataFrame[title, type, date, url]。"""
_patch_stock_map(monkeypatch, {"688017": "9900041602"})
monkeypatch.setattr(
"easy_tdx.cninfo.client._http_post_form",
lambda url, payload, timeout=15.0: _QUERY_RESPONSE,
)
from easy_tdx.cninfo import CninfoClient
df = CninfoClient().get_announcements("688017", count=30, page=1)
assert isinstance(df, pd.DataFrame)
assert list(df.columns) == ["title", "type", "date", "url"]
assert len(df) == 2
assert df.iloc[0]["title"] == "关于召开2025年年度股东大会的通知"
assert df.iloc[0]["type"] == "股东大会"
assert len(df.iloc[0]["date"]) == 10 # YYYY-MM-DD
assert df.iloc[0]["url"].endswith("abc123")
assert "cninfo.com.cn" in df.iloc[0]["url"]
def test_get_announcements_empty(monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any) -> None:
"""无公告应返回带列名的空 DataFrame。"""
_patch_stock_map(monkeypatch, {"688017": "9900041602"})
monkeypatch.setattr(
"easy_tdx.cninfo.client._http_post_form",
lambda url, payload, timeout=15.0: {"announcements": []},
)
from easy_tdx.cninfo import CninfoClient
df = CninfoClient().get_announcements("688017")
assert isinstance(df, pd.DataFrame)
assert df.empty
assert list(df.columns) == ["title", "type", "date", "url"]
def test_get_announcements_missing_key(
monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any
) -> None:
"""响应缺少 announcements 键应视为空结果。"""
_patch_stock_map(monkeypatch, {"688017": "9900041602"})
monkeypatch.setattr(
"easy_tdx.cninfo.client._http_post_form",
lambda url, payload, timeout=15.0: {"totalAnnouncement": 0},
)
from easy_tdx.cninfo import CninfoClient
df = CninfoClient().get_announcements("688017")
assert df.empty
def test_get_announcements_request_failure_raises_cninfo_error(
monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any
) -> None:
"""HTTP 异常应转为 CninfoError。"""
_patch_stock_map(monkeypatch, {"688017": "9900041602"})
def _boom(url: str, payload: dict[str, str], timeout: float = 15.0) -> Any:
raise OSError("connection refused")
monkeypatch.setattr("easy_tdx.cninfo.client._http_post_form", _boom)
from easy_tdx.cninfo import CninfoClient, CninfoError
with pytest.raises(CninfoError):
CninfoClient().get_announcements("688017")
def test_get_announcements_pagination(
monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any
) -> None:
"""count/page 应正确传入 payload。"""
_patch_stock_map(monkeypatch, {"688017": "9900041602"})
captured: dict[str, Any] = {}
def _capture(url: str, payload: dict[str, str], timeout: float = 15.0) -> Any:
captured.update(payload)
return {"announcements": []}
monkeypatch.setattr("easy_tdx.cninfo.client._http_post_form", _capture)
from easy_tdx.cninfo import CninfoClient
CninfoClient().get_announcements("688017", count=50, page=3)
assert captured["pageSize"] == "50"
assert captured["pageNum"] == "3"
assert captured["stock"] == "688017,9900041602"
assert captured["tabName"] == "fulltext"
def test_get_announcements_uses_fallback_orgid(
monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any
) -> None:
"""601xxx 段动态表未命中时用 gssh0 fallbackstock 字段含该 orgId。"""
_patch_stock_map(monkeypatch, {}) # 空表 → fallback
captured: dict[str, Any] = {}
def _capture(url: str, payload: dict[str, str], timeout: float = 15.0) -> Any:
captured.update(payload)
return {"announcements": []}
monkeypatch.setattr("easy_tdx.cninfo.client._http_post_form", _capture)
from easy_tdx.cninfo import CninfoClient
CninfoClient().get_announcements("601318")
assert captured["stock"] == "601318,gssh0601318"
def test_get_announcements_skips_non_dict_items(
monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any
) -> None:
"""announcements 列表中混入非 dict 元素应被跳过。"""
_patch_stock_map(monkeypatch, {"688017": "9900041602"})
monkeypatch.setattr(
"easy_tdx.cninfo.client._http_post_form",
lambda url, payload, timeout=15.0: {
"announcements": [
"not a dict",
{
"announcementTitle": "ok",
"announcementTime": 1749859200000,
"announcementId": "x",
},
]
},
)
from easy_tdx.cninfo import CninfoClient
df = CninfoClient().get_announcements("688017")
assert len(df) == 1
assert df.iloc[0]["title"] == "ok"
def test_get_announcements_response_not_dict(
monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any
) -> None:
"""响应非 dict(如 list)应视为空结果,不抛错。"""
_patch_stock_map(monkeypatch, {"688017": "9900041602"})
monkeypatch.setattr(
"easy_tdx.cninfo.client._http_post_form",
lambda url, payload, timeout=15.0: ["unexpected", "list"],
)
from easy_tdx.cninfo import CninfoClient
df = CninfoClient().get_announcements("688017")
assert df.empty
def test_get_announcements_malformed_timestamp_wrapped_as_cninfo_error(
monkeypatch: pytest.MonkeyPatch, reset_orgid_cache: Any
) -> None:
"""announcementTime 畸形(导致 fromtimestamp 溢出)应转 CninfoError,不裸抛。
回归 #2:修复前 _ts_to_date 在 try 块外,畸形时间戳会抛 OverflowError/
ValueError 裸异常;修复后整个解析路径统一转 CninfoError。
"""
_patch_stock_map(monkeypatch, {"688017": "9900041602"})
monkeypatch.setattr(
"easy_tdx.cninfo.client._http_post_form",
lambda url, payload, timeout=15.0: {
"announcements": [
{"announcementTitle": "x", "announcementTime": 10**30, "announcementId": "y"}
]
},
)
from easy_tdx.cninfo import CninfoClient, CninfoError
with pytest.raises(CninfoError):
CninfoClient().get_announcements("688017")
# ---------------------------------------------------------------------------
# urllib helper 烟雾测试(不触网,仅验证 JSON 解码路径)
# ---------------------------------------------------------------------------
def test_http_post_form_urlencoded_body(monkeypatch: pytest.MonkeyPatch) -> None:
"""_http_post_form 应以 application/x-www-form-urlencoded 发送。"""
import easy_tdx.cninfo.client as mod
captured: dict[str, Any] = {}
class _FakeResp:
def __init__(self, body: bytes) -> None:
self._body = body
def read(self) -> bytes:
return self._body
def __enter__(self) -> _FakeResp:
return self
def __exit__(self, *args: Any) -> None:
pass
def _fake_urlopen(req: Any, timeout: float = 15.0) -> _FakeResp:
captured["data"] = req.data
captured["headers"] = {k: v for k, v in req.header_items()}
captured["method"] = req.get_method()
return _FakeResp(json.dumps({"ok": True}).encode("utf-8"))
monkeypatch.setattr(mod.urlrequest, "urlopen", _fake_urlopen)
result = mod._http_post_form("https://example.com/api", {"pageNum": "2", "pageSize": "30"})
assert result == {"ok": True}
assert b"pageNum=2" in captured["data"]
assert b"pageSize=30" in captured["data"]
assert captured["method"] == "POST"
headers = {k.lower(): v for k, v in captured["headers"].items()}
assert "cninfo.com.cn" in headers.get("referer", "")
assert "x-www-form-urlencoded" in headers.get("content-type", "")
def test_http_get_json_headers(monkeypatch: pytest.MonkeyPatch) -> None:
"""_http_get_json 应携带 User-Agent。"""
import easy_tdx.cninfo.client as mod
captured: dict[str, Any] = {}
class _FakeResp:
def read(self) -> bytes:
return json.dumps({"ok": 1}).encode("utf-8")
def __enter__(self) -> _FakeResp:
return self
def __exit__(self, *args: Any) -> None:
pass
def _fake_urlopen(req: Any, timeout: float = 15.0) -> _FakeResp:
captured["headers"] = {k: v for k, v in req.header_items()}
return _FakeResp()
monkeypatch.setattr(mod.urlrequest, "urlopen", _fake_urlopen)
assert mod._http_get_json("https://example.com/x.json") == {"ok": 1}
headers = {k.lower(): v for k, v in captured["headers"].items()}
assert "user-agent" in headers
+1
View File
@@ -268,6 +268,7 @@ def test_full_app_routes_registered():
"/api/v1/xdxr",
"/api/v1/block",
"/api/v1/chanlun",
"/api/v1/announcements",
"/ws/realtime",
]
for prefix in expected_prefixes: