From f2bfa0210f78b5cca7006ace75107eded1b5edc5 Mon Sep 17 00:00:00 2001 From: Nicholas-Xiong <2482929840@qq.com> Date: Wed, 22 Jul 2026 20:54:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20TUSHARE=5FHTTP=5FU?= =?UTF-8?q?RL=20=E8=87=AA=E5=AE=9A=E4=B9=89=20Tushare=20Pro=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E5=9C=B0=E5=9D=80=20(#2048)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: support custom Tushare Pro endpoint via TUSHARE_HTTP_URL Add TUSHARE_HTTP_URL so the Tushare data source can point at a self-hosted or third-party compatible endpoint when the official api.tushare.pro is not reachable. Defaults to the official host when unset, so behavior is unchanged for existing users. - data_provider/tushare_fetcher.py: add _resolve_tushare_http_url() helper (strip + http(s):// schema validation) and forward the resolved URL into _TushareHttpClient, with an info log when a custom endpoint is in use - .env.example + .github/workflows/00-daily-analysis.yml: document and map TUSHARE_HTTP_URL so the new option is wired into the daily job without leaving a half-configured state - tests: cover env parsing (empty/whitespace/http/https/missing schema), fetcher fall-through to the official host, and end-to-end POST target - docs/CHANGELOG.md: flat [Unreleased] entries Fixes #1985 * refactor: drop unnecessary string-literal type hint in _build_api_client TushareHttpClient is already defined above TushareFetcher in the module scope, so a string-literal type hint is not needed for forward reference. Restore the bare type to match the surrounding code style and reduce the diff against main. * docs(tushare): 补 TUSHARE_HTTP_URL 在 full-guide 中英版本的用途/默认行为/workflow 映射说明 按 PR #2048 review 反馈补齐: - 表格内新增 TUSHARE_HTTP_URL 行(中英文版本同步),明确默认 https://api.tushare.pro 与 http(s):// 前缀要求 - 在 GitHub Actions 段落后补充 TUSHARE_HTTP_URL 的 vars/Secrets 优先级与每日 workflow 0映射说明,与现有非敏感配置(TICKFLOW_PRIORITY)一致 - 完整环境变量列表(中英文版本)补 TUSHARE_HTTP_URL 行,默认值列填 https://api.tushare.pro - 与代码实现一致:00-daily-analysis.yml 已用 vars.TUSHARE_HTTP_URL || secrets.TUSHARE_HTTP_URL 映射 * docs(tushare): align TUSHARE_HTTP_URL default with runtime and clarify vars/secrets precedence Per review feedback on PR #2048: the per-repo config contract must stay consistent across runtime, .env.example, workflow priority, tests and both zh/en guides. Two fixes applied as a single contract update: 1. Default endpoint alignment. data_provider/tushare_fetcher.py:100, .env.example, and tests/test_tushare_fetcher_http_client.py all keep the existing official endpoint http://api.tushare.pro, but the zh/en full-guide rows had drifted to https://api.tushare.pro. Switching the documented default to HTTPS would silently change the runtime contract that the feat commit explicitly preserved. Revert both zh and en guide rows to http://api.tushare.pro so docs match runtime, .env.example and the test assertions. 2. vars/secrets precedence wording. The workflow uses 'vars.TUSHARE_HTTP_URL || secrets.TUSHARE_HTTP_URL', which means a non-empty vars entry always wins and Secrets cannot override it. The zh/en notes previously suggested 'Secrets as a tamper fallback' which is incorrect under this precedence and can mislead users into thinking Secrets has override power. Replace with explicit description of the real semantics: vars wins when non-empty; Secrets is only selected when the Variable is empty; for a tamper-resistant deployment put the value only in Secrets and leave Variables empty. Both zh and en guides are updated together; the same 6 contract surfaces (runtime / .env.example / workflow priority / tests / zh guide / en guide) now describe one consistent contract. * docs(tushare): remove false Secret-as-tamper-guard claim, document real vars/secrets write-permission model --- .env.example | 5 ++ .github/workflows/00-daily-analysis.yml | 2 + data_provider/tushare_fetcher.py | 33 +++++++- docs/CHANGELOG.md | 2 + docs/full-guide.md | 4 + docs/full-guide_EN.md | 4 + tests/test_tushare_fetcher_http_client.py | 98 ++++++++++++++++++++++- 7 files changed, 146 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index fb273de86..0f8082838 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,11 @@ ANSPIRE_API_KEYS= # 数据源配置 # Tushare Pro Token(可选,从 https://tushare.pro/weborder/#/login?reg=834638 获取) TUSHARE_TOKEN= +# Tushare Pro 自定义接入地址(可选,默认 http://api.tushare.pro) +# 适用场景:网络无法直达官方接口时指向自建网关或第三方兼容镜像 +# 注意:使用非官方接入地址时,Token 与全部请求内容会经过该第三方服务器,请自行评估数据安全风险 +# 必须以 http:// 或 https:// 开头;留空则保持官方默认地址不变 +# TUSHARE_HTTP_URL=http://api.tushare.pro # TickFlow API Key(可选;用于 A 股日 K、实时行情、股票列表/名称与大盘复盘增强,权限不足自动回退) # TICKFLOW_API_KEY= # TICKFLOW_KLINE_ADJUST=none # none/forward/backward/forward_additive/backward_additive diff --git a/.github/workflows/00-daily-analysis.yml b/.github/workflows/00-daily-analysis.yml index e4c5973e3..392f94e6b 100644 --- a/.github/workflows/00-daily-analysis.yml +++ b/.github/workflows/00-daily-analysis.yml @@ -255,6 +255,8 @@ jobs: # 数据源 # ========================================== TUSHARE_TOKEN: ${{ secrets.TUSHARE_TOKEN }} + # Tushare Pro 自定义接入地址(可选;指向自建网关或第三方兼容镜像时设置) + TUSHARE_HTTP_URL: ${{ vars.TUSHARE_HTTP_URL || secrets.TUSHARE_HTTP_URL }} # TickFlow(A 股日 K、实时行情、股票列表/名称与大盘复盘增强;不设则走免费源 fallback) TICKFLOW_API_KEY: ${{ secrets.TICKFLOW_API_KEY }} TICKFLOW_PRIORITY: ${{ vars.TICKFLOW_PRIORITY || secrets.TICKFLOW_PRIORITY }} diff --git a/data_provider/tushare_fetcher.py b/data_provider/tushare_fetcher.py index 37138d03b..ece41f2d5 100644 --- a/data_provider/tushare_fetcher.py +++ b/data_provider/tushare_fetcher.py @@ -72,6 +72,28 @@ def _is_us_code(stock_code: str) -> bool: return bool(re.match(r'^[A-Z]{1,5}(\.[A-Z])?$', code)) +def _resolve_tushare_http_url() -> Optional[str]: + """读取 ``TUSHARE_HTTP_URL`` 环境变量并做基本校验。 + + - 留空 / 仅空白 / 未设置 → 返回 ``None``,调用方继续走官方默认地址。 + - 设置则去掉首尾空白后返回,并校验必须是 ``http://`` 或 ``https://`` 前缀, + 避免有人误填成纯主机名(如 ``api.tushare.pro``)导致 ``requests`` 把它 + 当成相对路径请求失败。 + """ + raw = os.getenv("TUSHARE_HTTP_URL") + if not raw: + return None + url = raw.strip() + if not url: + return None + if not (url.startswith("http://") or url.startswith("https://")): + raise ValueError( + "TUSHARE_HTTP_URL 必须以 http:// 或 https:// 开头," + f"当前值为 {url!r}" + ) + return url + + class _TushareHttpClient: """Lightweight Tushare Pro client that does not require the tushare SDK.""" @@ -177,8 +199,17 @@ class TushareFetcher(BaseFetcher): The project already normalizes all Pro calls through the same request contract, so we do not need the official tushare SDK during runtime. + + 支持通过 ``TUSHARE_HTTP_URL`` 环境变量将请求指向自建或第三方兼容 + 端点,便于在网络无法直达 ``api.tushare.pro`` 时切换镜像/网关。 + 留空或不设置则保持官方默认地址,行为与历史版本完全一致。 """ - client = _TushareHttpClient(token=token) + api_url = _resolve_tushare_http_url() + if api_url: + logger.info("Tushare 使用自定义接入地址: %s", api_url) + client = _TushareHttpClient(token=token, api_url=api_url) + else: + client = _TushareHttpClient(token=token) logger.debug("Tushare API client configured for direct HTTP calls") return client diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d6f63c723..178b7b7f3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - [修复] #2026 外股代码映射到中文显示名时英文新闻相关性判定漏判:新增同源 STOCK_ENGLISH_NAME_MAP 单一真源、canonicalize_foreign_stock_code 规范化入口与 _foreign_english_query_terms 别名解析,使 AAPL/00700/BABA 等 ticker 即使 stock_name 为中文也能在查询构建、相关性打分与多维度情报路径上复用 canonical 英文名,并补齐 .US/.HK suffix / HK 前缀全形式的归类与回归用例;同时在 _score_news_relevance 对 alias 展开 term 做去重,避免 legal alias 展开短名与显式 short alias 重复计分。 +- [新功能] Tushare 数据源支持通过 `TUSHARE_HTTP_URL` 环境变量自定义接入地址,便于网络无法直达 `api.tushare.pro` 时切换自建网关或第三方兼容镜像;留空保持官方默认地址不变(fixes #1985) +- [文档] `.env.example` 与 `.github/workflows/00-daily-analysis.yml` 同步映射 `TUSHARE_HTTP_URL`,避免出现"配置项有但 workflow 漏映射"的半修状态 - [修复] #2051 PR Review 的特权 `pull_request_target` 流程不再检出 fork PR head:敏感文件、标签、报告与 AI 审查统一通过 GitHub API 将 PR 元数据和 diff 作为数据读取,只执行主分支可信脚本;Python 语法、Flake8、确定性检查和离线测试继续由无 secrets 的 `pull_request` CI / `backend-gate` 执行,兼容 `actions/checkout` 新增的 fork checkout 安全保护。 ## [3.27.0] - 2026-07-19 diff --git a/docs/full-guide.md b/docs/full-guide.md index a0e45b693..e76f84f92 100644 --- a/docs/full-guide.md +++ b/docs/full-guide.md @@ -163,6 +163,7 @@ daily_stock_analysis/ | `SEARXNG_BASE_URLS` | SearXNG 自建实例(无配额兜底,需在 settings.yml 启用 format: json);留空时默认自动发现公共实例 | 可选 | | `SEARXNG_PUBLIC_INSTANCES_ENABLED` | 是否在 `SEARXNG_BASE_URLS` 为空时自动从 `searx.space` 获取公共实例(默认 `true`) | 可选 | | `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638 ) Token | 可选 | +| `TUSHARE_HTTP_URL` | Tushare Pro HTTP 接入地址;留空(或未设置/空白)时使用官方端点 `http://api.tushare.pro`,仅在需通过公司内网代理、跨境网络或自建镜像时填写 `http://` 或 `https://` 开头的完整地址 | 可选 | | `TICKFLOW_API_KEY` | [TickFlow](https://tickflow.org) API Key;可选,用于 A 股日 K、实时行情、股票列表/名称与大盘复盘增强;失败或权限不足时自动回退。 | 可选 | | `LONGBRIDGE_OAUTH_CLIENT_ID` | [Longbridge OpenAPI](https://open.longbridge.com/) OAuth client_id;留空且无 Legacy Access Token 时会兼容使用 `LONGBRIDGE_APP_KEY` | 可选 | | `LONGBRIDGE_OAUTH_TOKEN_CACHE_B64` | OAuth token 缓存文件的 base64 内容,供 GitHub Actions / Docker 等 headless 环境恢复 SDK token 缓存 | 可选 | @@ -182,6 +183,8 @@ daily_stock_analysis/ > **GitHub Actions:** 仓库自带 `00-daily-analysis.yml` 已把 `TUSHARE_TOKEN`、`TICKFLOW_API_KEY` / `TICKFLOW_*` 和上表中的 `LONGBRIDGE_*` 映射到任务环境。TickFlow 的 API Key 建议放在 **Secrets**,优先级、复权和批量开关可放在 **Variables** 或 **Secrets**。Longbridge OAuth 方式需要一个 client_id(优先 `LONGBRIDGE_OAUTH_CLIENT_ID`;留空且无 Legacy Access Token 时使用 `LONGBRIDGE_APP_KEY` 兼容),并把本机 `~/.longbridge/openapi/tokens/` 文件 base64 后保存为 Secret `LONGBRIDGE_OAUTH_TOKEN_CACHE_B64`;Legacy 方式仍可配置 `LONGBRIDGE_APP_KEY`、`LONGBRIDGE_APP_SECRET`、`LONGBRIDGE_ACCESS_TOKEN`。可选接入点变量(如 `LONGBRIDGE_REGION`)可放在 **Variables** 或 **Secrets**。 +> **TUSHARE_HTTP_URL 在每日 workflow 中的映射:** `00-daily-analysis.yml` 已显式映射 `TUSHARE_HTTP_URL`(采用 `vars.TUSHARE_HTTP_URL || secrets.TUSHARE_HTTP_URL` 优先级,与仓库现有 `TICKFLOW_PRIORITY` 等非敏感配置取值策略一致)。该地址属"接入地址"配置而非凭据,建议放 **Variables** 便于团队 review 与版本可审计。注意:真实的优先级是 `vars` 非空即胜出,**Secrets 中的同名变量无法覆盖非空 Variables**,两者中只有 vars 为空时 secrets 才被采用,请按这个真实语义做安全建模。GitHub 把 Variables 与 Secrets 设计为两套独立的写权限模型:任何对 repository Variables 有写权限的人或自动化,都可以在不读取、不修改 Secrets 的情况下,通过设置一个非空 Variable 来改写运行时端点(包括将 `TUSHARE_TOKEN` 和完整请求体指向攻击者控制的地址);Secrets 仅保护值的机密性,并不自动提供"端点完整性"或"优先级覆盖"保障。如确需对端点施加更强的访问控制,请使用 GitHub Environment protection rules、CODEOWNERS、branch protection 或独立的部署审批流程,**不要把"只放 Secrets 而 Variables 留空"当作防改护栏**。未设置或留空时 fetcher 仍走官方 `http://api.tushare.pro` 端点,不会因为本变量缺失而报错。 + > **Longbridge 运行时行为:** 未配置凭据时不会实例化 Longbridge 这个可选 fetcher;若运行时遇到 `client is closed`、`context closed`、`connection closed` 等连接关闭类异常,会进入冷却期(默认 15 秒,可用 `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` 调整),冷却期内美股/港股的实时与日线请求会自动跳过 Longbridge,退回 YFinance / AkShare 等兜底链路。 > 补充说明 @@ -405,6 +408,7 @@ daily_stock_analysis/ | 变量名 | 说明 | 默认值 | 必填 | |--------|------|--------|:----:| | `TUSHARE_TOKEN` | Tushare Pro Token | - | 可选 | +| `TUSHARE_HTTP_URL` | Tushare Pro HTTP 接入地址;留空时使用官方端点 `http://api.tushare.pro`,仅在需通过公司内网代理、跨境网络或自建镜像时填 `http://` 或 `https://` 开头的完整地址 | `http://api.tushare.pro` | 可选 | | `TICKFLOW_API_KEY` | TickFlow API Key;可选,用于 A 股日 K、实时行情、股票列表/名称与大盘复盘增强;失败或权限不足时自动回退。 | - | 可选 | | `TICKFLOW_PRIORITY` | TickFlow 日 K 数据源优先级;数字越小越早尝试,默认 `2`;未配置 API Key 时不启用;不影响实时行情,实时行情顺序由 `REALTIME_SOURCE_PRIORITY` 控制。 | `2` | 可选 | | `TICKFLOW_KLINE_ADJUST` | TickFlow 日 K 复权模式:`none`、`forward`、`backward`、`forward_additive`、`backward_additive`。 | `none` | 可选 | diff --git a/docs/full-guide_EN.md b/docs/full-guide_EN.md index 9c2a6c789..e906a0fb2 100644 --- a/docs/full-guide_EN.md +++ b/docs/full-guide_EN.md @@ -153,10 +153,13 @@ Go to your forked repo → `Settings` → `Secrets and variables` → `Actions` | `SEARXNG_BASE_URLS` | SearXNG self-hosted instances (quota-free fallback, enable format: json in settings.yml); when empty the app auto-discovers public instances | Optional | | `SEARXNG_PUBLIC_INSTANCES_ENABLED` | Auto-discover public SearXNG instances from `searx.space` when `SEARXNG_BASE_URLS` is empty (default `true`) | Optional | | `TUSHARE_TOKEN` | [Tushare Pro](https://tushare.pro/weborder/#/login?reg=834638) Token | Optional | +| `TUSHARE_HTTP_URL` | Tushare Pro HTTP endpoint; when unset/empty defaults to the official `http://api.tushare.pro`. Set to a `http://` or `https://` URL only when routing through a corporate proxy, cross-border network, or a self-hosted mirror | Optional | | `TICKFLOW_API_KEY` | [TickFlow](https://tickflow.org) API key for optional A-share daily K-lines, realtime quotes, stock list/name lookup, and CN market review enhancement; permission or entitlement failures fall back to existing providers | Optional | > **GitHub Actions:** The bundled `00-daily-analysis.yml` maps `TUSHARE_TOKEN`, `TICKFLOW_API_KEY` / `TICKFLOW_*`, and the documented `LONGBRIDGE_*` variables into the job environment. Store `TICKFLOW_API_KEY` in **Secrets**; non-sensitive TickFlow priority, adjustment, and batch switches can live in **Variables** or **Secrets**. Longbridge OAuth still requires a client id plus `LONGBRIDGE_OAUTH_TOKEN_CACHE_B64` for headless Actions runs, while the legacy `LONGBRIDGE_APP_KEY` / `LONGBRIDGE_APP_SECRET` / `LONGBRIDGE_ACCESS_TOKEN` triplet remains supported. +> **`TUSHARE_HTTP_URL` mapping in the daily workflow:** `00-daily-analysis.yml` maps `TUSHARE_HTTP_URL` with `vars.TUSHARE_HTTP_URL || secrets.TUSHARE_HTTP_URL` — the same vars-first precedence used for `TICKFLOW_PRIORITY` and other non-sensitive knobs. The endpoint is an "address" rather than a credential, so **Variables** is preferred for team review and audit visibility. Note that the real precedence is "vars wins when non-empty": a **Secrets** entry with the same name is **not** a tamper fallback that overrides a non-empty Variable — Secrets is only selected when the Variable is empty. Build your threat model on that actual semantics. GitHub exposes Variables and Secrets as two independent write-permission models: anyone (human or automation) with write access to repository Variables can set a non-empty Variable and silently reroute the runtime endpoint — including `TUSHARE_TOKEN` and the full request body to an attacker-controlled URL — without reading or modifying any Secret. Secrets only protect value confidentiality; they do **not** provide "endpoint integrity" or "priority override" guarantees. If you need stronger access control over the endpoint, use GitHub Environment protection rules, CODEOWNERS, branch protection, or a dedicated deployment approval flow — **do not treat "put it in Secrets and leave Variables empty" as a tamper guard**. Leaving it unset or empty preserves the default `http://api.tushare.pro` endpoint — the fetcher does not error when this variable is missing. + #### ✅ Minimum Configuration Example To get started quickly, you need at minimum: @@ -343,6 +346,7 @@ For the notification baseline, diagnostics, and deployment notes, see [Notificat | Variable | Description | Default | Required | |--------|------|--------|:----:| | `TUSHARE_TOKEN` | Tushare Pro Token | - | Optional | +| `TUSHARE_HTTP_URL` | Tushare Pro HTTP endpoint; defaults to `http://api.tushare.pro` when unset/empty. Set only when routing through a corporate proxy, cross-border network, or a self-hosted mirror (must start with `http://` or `https://`). | `http://api.tushare.pro` | Optional | | `TICKFLOW_API_KEY` | TickFlow API key; enables optional A-share daily K-lines, realtime quotes, stock list/name lookup, and CN market review enhancement. Permission failures fall back to existing providers. | - | Optional | | `TICKFLOW_PRIORITY` | TickFlow daily K-line provider priority; lower values are tried earlier. No effect unless `TICKFLOW_API_KEY` is configured. Does not affect realtime quotes, which are ordered by `REALTIME_SOURCE_PRIORITY`. | `2` | Optional | | `TICKFLOW_KLINE_ADJUST` | TickFlow daily K-line adjustment mode: `none`, `forward`, `backward`, `forward_additive`, or `backward_additive`. | `none` | Optional | diff --git a/tests/test_tushare_fetcher_http_client.py b/tests/test_tushare_fetcher_http_client.py index f38afedb0..360aa0caa 100644 --- a/tests/test_tushare_fetcher_http_client.py +++ b/tests/test_tushare_fetcher_http_client.py @@ -20,7 +20,11 @@ except ValueError: if not json_repair_available and "json_repair" not in sys.modules: sys.modules["json_repair"] = MagicMock() -from data_provider.tushare_fetcher import TushareFetcher, _TushareHttpClient +from data_provider.tushare_fetcher import ( + TushareFetcher, + _TushareHttpClient, + _resolve_tushare_http_url, +) class TestTushareHttpClient(unittest.TestCase): @@ -75,5 +79,97 @@ class TestTushareFetcherInit(unittest.TestCase): self.assertEqual(fetcher.priority, -1) +class TestResolveTushareHttpUrl(unittest.TestCase): + """``TUSHARE_HTTP_URL`` 环境变量解析与校验。""" + + def test_unset_returns_none(self) -> None: + with patch.dict("os.environ", {}, clear=False): + import os + os.environ.pop("TUSHARE_HTTP_URL", None) + self.assertIsNone(_resolve_tushare_http_url()) + + def test_empty_or_whitespace_returns_none(self) -> None: + with patch.dict("os.environ", {"TUSHARE_HTTP_URL": " "}): + self.assertIsNone(_resolve_tushare_http_url()) + + def test_valid_http_url_returned_stripped(self) -> None: + with patch.dict("os.environ", {"TUSHARE_HTTP_URL": " http://gw.example.com/tushare "}): + self.assertEqual(_resolve_tushare_http_url(), "http://gw.example.com/tushare") + + def test_https_url_returned(self) -> None: + with patch.dict("os.environ", {"TUSHARE_HTTP_URL": "https://gw.example.com/tushare"}): + self.assertEqual(_resolve_tushare_http_url(), "https://gw.example.com/tushare") + + def test_missing_schema_raises_value_error(self) -> None: + # 防止有人误填纯主机名(如 'api.tushare.pro')后被 requests 当成相对路径 + with patch.dict("os.environ", {"TUSHARE_HTTP_URL": "gw.example.com"}): + with self.assertRaises(ValueError): + _resolve_tushare_http_url() + + +class TestTushareFetcherCustomHttpUrl(unittest.TestCase): + """``TUSHARE_HTTP_URL`` 真正打通到 HTTP client 的接入地址。""" + + def test_fetcher_uses_custom_url_when_env_set(self) -> None: + config = SimpleNamespace(tushare_token="demo-token") + + with patch("data_provider.tushare_fetcher.get_config", return_value=config), \ + patch.dict("os.environ", {"TUSHARE_HTTP_URL": "http://gw.example.com/tushare"}): + fetcher = TushareFetcher() + + self.assertIsInstance(fetcher._api, _TushareHttpClient) + self.assertEqual(fetcher._api._api_url, "http://gw.example.com/tushare") + + def test_fetcher_falls_back_to_official_when_env_empty(self) -> None: + config = SimpleNamespace(tushare_token="demo-token") + + env = {k: v for k, v in __import__("os").environ.items() if k != "TUSHARE_HTTP_URL"} + with patch("data_provider.tushare_fetcher.get_config", return_value=config), \ + patch.dict("os.environ", env, clear=True): + fetcher = TushareFetcher() + + self.assertIsInstance(fetcher._api, _TushareHttpClient) + self.assertEqual(fetcher._api._api_url, "http://api.tushare.pro") + + def test_query_posts_to_custom_endpoint(self) -> None: + """端到端确保自定义 url 真正驱动 requests.post 的目标地址。""" + config = SimpleNamespace(tushare_token="demo-token") + + with patch("data_provider.tushare_fetcher.get_config", return_value=config), \ + patch.dict("os.environ", {"TUSHARE_HTTP_URL": "http://gw.example.com/tushare"}): + fetcher = TushareFetcher() + + response = MagicMock( + status_code=200, + text=json.dumps( + { + "code": 0, + "data": { + "fields": ["ts_code", "close"], + "items": [["600519.SH", 1688.0]], + }, + } + ), + ) + + with patch("data_provider.tushare_fetcher.requests.post", return_value=response) as post_mock: + fetcher._api.daily(ts_code="600519.SH", start_date="20260320", end_date="20260325") + + post_mock.assert_called_once_with( + "http://gw.example.com/tushare", + json={ + "api_name": "daily", + "token": "demo-token", + "params": { + "ts_code": "600519.SH", + "start_date": "20260320", + "end_date": "20260325", + }, + "fields": "", + }, + timeout=30, + ) + + if __name__ == "__main__": unittest.main()