"""FuyaoProvider 契约与单位标准化测试。 不依赖真实网络: 用假 FuyaoClient 返回样例快照页/历史K线/dump, 验证字段映射、 单位口径 (CONTRIBUTING §3.1: 百分数→小数、volume 股→手)、日K取数分档 (10d dump / 单标的接口)、除权因子推导 (交易所公式 + half-up 舍入 + 同日合并 + 涨跌停自检)、能力声明 (未声明数据集回退 tickflow) 与设置页试拉。 """ from __future__ import annotations import calendar import itertools from datetime import date, datetime, timedelta import polars as pl import pytest from app.plugins.fuyao import client as fc from app.plugins.fuyao import provider as fp from app.plugins.fuyao.provider import FuyaoProvider class _FakeClient: """按调用次数返回预置页, 记录调用供分页断言。snapshot_all 同真实客户端语义。""" def __init__( self, pages: list[list[dict]], count: int, error: Exception | None = None, server_ts: int = 0, ): self.pages = pages self.count = count self.error = error self.server_ts = server_ts self.calls: list[dict] = [] self.last_server_ts = server_ts def snapshot_page(self, limit=500, offset=0): self.calls.append({"limit": limit, "offset": offset}) if self.error: raise self.error if not self.pages: return [], self.count # 按调用次数取页(provider 每轮 offset += len(rows), 页序与调用序一致) idx = min(len(self.calls) - 1, len(self.pages) - 1) return list(self.pages[idx]), self.count def snapshot_all(self): """与真实客户端同语义的分页循环 (无页间隔, 测试用)。返回 (rows, server_ts)。""" out: list[dict] = [] offset = 0 for _ in range(50): rows, count = self.snapshot_page(offset=offset) if not rows: break out.extend(rows) if count and len(out) >= count: break offset += len(rows) if not out: raise fc.FuyaoError("全市场快照为空") return out, self.server_ts def close(self): pass def _row(thscode: str = "600519.SH", **over): """实测快照行结构(2026-08): high_price/low_price/prev_price 命名。""" row = { "thscode": thscode, "last_price": 1480.0, "price_change": 25.0, "price_change_ratio_pct": 1.72, "open_price": 1460.0, "high_price": 1490.5, "low_price": 1455.0, "prev_price": 1455.0, "volume": 1234500, "turnover": 1.83e9, } row.update(over) return row def _provider_with(monkeypatch, pages, count=None, error=None, **fake_kwargs): fake = _FakeClient( pages, count if count is not None else sum(len(p) for p in pages), error, **fake_kwargs ) monkeypatch.setattr(fp, "fuyao_client", type("M", (), {"FuyaoClient": lambda **kw: fake})) monkeypatch.setattr(fp, "get_api_key", lambda: "test-key") return FuyaoProvider(), fake # ---- 单位与字段映射 ---- def test_snapshot_units_and_field_mapping(monkeypatch): """核心口径: price_change_ratio_pct 百分数 → change_pct 小数制 (1.72 → 0.0172)。""" provider, _ = _provider_with(monkeypatch, [[_row()]]) records = provider.get_realtime() assert len(records) == 1 r = records[0] assert r["symbol"] == "600519.SH" assert r["change_pct"] == pytest.approx(0.0172) assert r["change_amount"] == pytest.approx(25.0) assert r["prev_close"] == 1455.0 assert r["open"] == 1460.0 assert r["high"] == 1490.5 assert r["low"] == 1455.0 # volume 单位股(1234500) → 内部契约手: floor(1234500/100) = 12345 assert r["volume"] == 12345 assert r["amount"] == 1.83e9 assert r["timestamp"] > 0 # 快照不提供的字段必须为 None, 不启发式伪造 assert r["name"] is None assert r["amplitude"] is None assert r["turnover_rate"] is None def test_missing_pct_derives_decimal_from_change_amount(monkeypatch): """涨跌幅缺失时按 change_amount/prev_close 推导, 仍为小数制。""" provider, _ = _provider_with(monkeypatch, [[_row(price_change_ratio_pct=None)]]) r = provider.get_realtime()[0] assert r["change_pct"] == pytest.approx(25.0 / 1455.0) assert r["change_amount"] == pytest.approx(25.0) def test_missing_change_amount_derived_from_prices(monkeypatch): provider, _ = _provider_with(monkeypatch, [[_row(price_change=None)]]) r = provider.get_realtime()[0] assert r["change_amount"] == pytest.approx(1480.0 - 1455.0) def test_row_without_thscode_dropped(monkeypatch): provider, _ = _provider_with(monkeypatch, [[_row(), {"last_price": 1.0}, _row("000001.SZ")]]) records = provider.get_realtime() assert [r["symbol"] for r in records] == ["600519.SH", "000001.SZ"] def test_all_rows_unrecognized_returns_empty_with_no_fake_data(monkeypatch): provider, _ = _provider_with(monkeypatch, [[{"foo": "bar"}, {"baz": 1}]]) assert provider.get_realtime() == [] # ---- 客户端信封解析 (实测结构 vs 文档示例) ---- def _patch_http(monkeypatch, payload, status_code=200): class _Resp: def json(self): return payload _Resp.status_code = status_code class _Http: def get(self, path, params=None): return _Resp() def close(self): pass monkeypatch.setattr(fc.httpx, "Client", lambda **kw: _Http()) def test_client_parses_real_world_envelope(monkeypatch): """实测信封(2026-08): data={timestamp, total, item}。""" _patch_http( monkeypatch, { "code": 0, "message": "success", "data": {"timestamp": 1787542612000, "total": 2, "item": [_row(), _row("000001.SZ")]}, }, ) c = fc.FuyaoClient(api_key="k") rows, total = c.snapshot_page() assert total == 2 and len(rows) == 2 assert c.last_server_ts == 1787542612000 def test_client_parses_documented_envelope(monkeypatch): """官方文档示例信封: data={count, data}。""" _patch_http( monkeypatch, { "code": 0, "message": "OK", "data": {"count": 3, "data": [_row()]}, }, ) c = fc.FuyaoClient(api_key="k") rows, total = c.snapshot_page() assert total == 3 and len(rows) == 1 def test_client_raises_on_error_code(monkeypatch): _patch_http(monkeypatch, {"code": 4001, "message": "rate limited", "data": None}) c = fc.FuyaoClient(api_key="k") with pytest.raises(fc.FuyaoError, match="4001"): c.snapshot_page() # ---- 字段名兼容与服务端时间戳 ---- def test_doc_style_field_names_fallback(monkeypatch): """文档示例字段名 (highest_price/lowest_price/prev_close_price) 也能映射。""" row = { "thscode": "600519.SH", "last_price": 1480.0, "price_change": 25.0, "price_change_ratio_pct": 1.72, "open_price": 1460.0, "highest_price": 1490.5, "lowest_price": 1455.0, "prev_close_price": 1455.0, "volume": 1234500, "turnover": 1.83e9, } provider, _ = _provider_with(monkeypatch, [[row]]) r = provider.get_realtime()[0] assert r["high"] == 1490.5 assert r["low"] == 1455.0 assert r["prev_close"] == 1455.0 def test_realtime_uses_server_timestamp(monkeypatch): provider, _ = _provider_with(monkeypatch, [[_row()]], server_ts=1787542612000) assert provider.get_realtime()[0]["timestamp"] == 1787542612000 def test_realtime_falls_back_to_local_time_without_server_ts(monkeypatch): provider, _ = _provider_with(monkeypatch, [[_row()]], server_ts=0) assert provider.get_realtime()[0]["timestamp"] > 0 # ---- 分页 ---- def test_snapshot_pagination_merges_pages(monkeypatch): page1 = [_row(f"{600000 + i}.SH") for i in range(2)] page2 = [_row(f"{688000 + i}.SH") for i in range(1)] provider, fake = _provider_with(monkeypatch, [page1, page2], count=3) records = provider.get_realtime() assert len(records) == 3 assert len(fake.calls) == 2 assert fake.calls[1]["offset"] == 2 def test_snapshot_stops_when_page_empty(monkeypatch): provider, fake = _provider_with(monkeypatch, [[_row()], []], count=0) assert len(provider.get_realtime()) == 1 assert len(fake.calls) == 2 # ---- 软失败 ---- def test_realtime_error_returns_empty_list(monkeypatch): provider, _ = _provider_with( monkeypatch, [[]], error=fc.FuyaoError("扶摇接口错误 code=4001: 频率超限") ) assert provider.get_realtime() == [] def test_client_requires_api_key(): with pytest.raises(fc.FuyaoError): fc.FuyaoClient(api_key="") # ---- 能力声明与注册 ---- def test_datasets_declaration(): """声明 realtime/daily/adj_factor/financial; minute 未声明 (回退 tickflow)。""" config = FuyaoProvider().config assert "realtime" in config.datasets assert "daily" in config.datasets assert "adj_factor" in config.datasets assert "financial" in config.datasets assert "minute" not in config.datasets # ---- API Key 解析 (secrets.json > .env, 对齐 tickflow 语义) ---- def test_get_api_key_secrets_store_takes_priority(monkeypatch): from app import secrets_store monkeypatch.delenv(fp.API_KEY_ENV, raising=False) monkeypatch.setattr(secrets_store, "load", lambda: {fp.SECRETS_FIELD: "sk-from-ui"}) assert fp.get_api_key() == "sk-from-ui" def test_get_api_key_falls_back_to_env(monkeypatch): from app import secrets_store monkeypatch.setenv(fp.API_KEY_ENV, "sk-from-env") monkeypatch.setattr(secrets_store, "load", lambda: {}) assert fp.get_api_key() == "sk-from-env" def test_availability_accepts_secrets_store_key(monkeypatch): from app import secrets_store monkeypatch.delenv(fp.API_KEY_ENV, raising=False) monkeypatch.setattr(secrets_store, "load", lambda: {fp.SECRETS_FIELD: "sk-from-ui"}) assert fp.availability() == (True, "ok") def test_availability_requires_env_key(monkeypatch): from app import secrets_store monkeypatch.delenv(fp.API_KEY_ENV, raising=False) monkeypatch.setattr(secrets_store, "load", lambda: {}) ok, reason = fp.availability() assert ok is False and fp.API_KEY_ENV in reason # ---- 先探后存 (probe_api_key) ---- def _patch_client_cls(monkeypatch, fake): monkeypatch.setattr(fp, "fuyao_client", type("M", (), {"FuyaoClient": lambda **kw: fake})) def test_probe_api_key_ok(monkeypatch): _patch_client_cls(monkeypatch, _FakeClient([[_row()]], 1)) ok, reason = fp.probe_api_key("sk-candidate") assert ok is True and reason == "ok" def test_probe_api_key_invalid_key(monkeypatch): _patch_client_cls( monkeypatch, _FakeClient([[]], 0, error=fc.FuyaoError("扶摇接口错误 code=1001: 无效 api key")), ) ok, reason = fp.probe_api_key("sk-bad") assert ok is False and "无效" in reason def test_loader_probe_plugin_key_dispatch(monkeypatch): import app.plugins.fuyao.provider as provider_mod from app.data_providers.custom import loader monkeypatch.setattr( provider_mod, "probe_api_key", lambda key: (True, "ok") if key == "good" else (False, "bad") ) assert loader.probe_plugin_key("fuyao", "good") == (True, "ok") assert loader.probe_plugin_key("fuyao", "bad") == (False, "bad") def test_loader_probe_plugin_key_unsupported_plugin(): from app.data_providers.custom import loader # stock-sdk 未声明 api_key_env → 不支持界面配 Key ok, reason = loader.probe_plugin_key("stocksdk", "x") assert ok is False and "不支持" in reason ok, reason = loader.probe_plugin_key("no_such_plugin", "x") assert ok is False and "不存在" in reason # ---- 保存/清除端点 (直接调用 handler, 先探后存语义) ---- def test_save_plugin_key_invalid_key_not_persisted(monkeypatch): from app.api import settings as settings_api from app.data_providers import custom as custom_sources saved: dict = {} monkeypatch.setattr(custom_sources, "probe_plugin_key", lambda n, k: (False, "Key 无效")) monkeypatch.setattr( settings_api.secrets_store, "save", lambda updates: saved.update(updates) or updates ) out = settings_api.save_plugin_key(settings_api.PluginKeyIn(plugin="fuyao", api_key="bad")) assert out["ok"] is False and out["reason"] == "invalid" assert saved == {} # 无效 Key 不落盘 def test_save_plugin_key_valid_persists_and_rescans(monkeypatch): from app.api import settings as settings_api from app.data_providers import custom as custom_sources saved: dict = {} reloaded = [] monkeypatch.setattr(custom_sources, "probe_plugin_key", lambda n, k: (True, "ok")) monkeypatch.setattr( settings_api.secrets_store, "save", lambda updates: saved.update(updates) or updates ) monkeypatch.setattr( settings_api.secrets_store, "mask", lambda key, prefix=4, suffix=4: "abcd••••wxyz" ) monkeypatch.setattr(custom_sources, "load_all", lambda: reloaded.append(1)) monkeypatch.setattr( custom_sources, "list_plugins", lambda: [{"name": "fuyao", "available": True}] ) out = settings_api.save_plugin_key(settings_api.PluginKeyIn(plugin="fuyao", api_key="good-key")) assert out["ok"] is True assert saved == {"fuyao_api_key": "good-key"} # 字段名与 provider.SECRETS_FIELD 一致 assert out["plugin_available"] is True assert reloaded == [1] # 保存后重扫, 插件即刻可用 def test_clear_plugin_key(monkeypatch): from app.api import settings as settings_api from app.data_providers import custom as custom_sources cleared: list = [] monkeypatch.setattr(custom_sources, "is_builtin", lambda n: n == "fuyao") monkeypatch.setattr(settings_api.secrets_store, "clear", lambda *keys: cleared.extend(keys)) monkeypatch.setattr(custom_sources, "load_all", lambda: None) monkeypatch.setattr( custom_sources, "list_plugins", lambda: [{"name": "fuyao", "available": False}] ) out = settings_api.clear_plugin_key("fuyao") assert out["ok"] is True and out["plugin_available"] is False assert cleared == ["fuyao_api_key"] def test_manifest_declares_datasets(): from app.data_providers.custom import loader manifest = loader.plugin_manifest("fuyao") assert manifest is not None assert manifest["entry"] == "app.plugins.fuyao.provider:FuyaoProvider" assert {"realtime", "daily", "adj_factor"} <= set(manifest.get("datasets") or []) assert manifest.get("runtime") == "none" assert manifest.get("api_key_env") == fp.API_KEY_ENV def test_hidden_plugin_not_registered(): """fuyao 已取消隐藏 (plugin.yaml 不再声明 hidden); hidden 机制本身仍生效。 用合成清单验证: hidden: true 的插件不注册、不在数据源页展示。 """ from app.data_providers.custom import loader manifest = loader.plugin_manifest("fuyao") assert manifest is not None and not manifest.get("hidden"), ( "fuyao 应保持可见; 如需重新隐藏请在 plugin.yaml 声明 hidden 并更新本测试" ) loader._register_one_plugin(manifest) assert "fuyao" in loader._PLUGIN_STATUS hidden_manifest = dict(manifest, name="hidden_probe", hidden=True) loader._register_one_plugin(hidden_manifest) assert "hidden_probe" not in loader._PLUGIN_STATUS assert "hidden_probe" not in loader._PROVIDERS # ---- 插件 Key 脱敏展示 (与 TickFlow Key 契约一致) ---- def test_plugin_key_masked_from_secrets_then_env(monkeypatch): """api_key_masked 随插件状态返回: secrets.json 优先, .env 兜底, 未配置为空。 完整 Key 不出后端, 只出 mask() 结果 — 与 settings API 的 tickflow_api_key_masked 同一展示契约。 """ from app import secrets_store from app.data_providers.custom import loader # 未声明 api_key_env / 未配置 Key → 空 assert loader._plugin_key_masked("x", "") == "" monkeypatch.setattr(secrets_store, "load", lambda: {}) monkeypatch.delenv(fp.API_KEY_ENV, raising=False) assert loader._plugin_key_masked("fuyao", fp.API_KEY_ENV) == "" # .env 兜底 monkeypatch.setenv(fp.API_KEY_ENV, "env-secret-key-123456") assert loader._plugin_key_masked("fuyao", fp.API_KEY_ENV) == "env-••••••3456" # secrets.json 优先于 .env monkeypatch.setattr(secrets_store, "load", lambda: {"fuyao_api_key": "stored-secret-key-999"}) assert loader._plugin_key_masked("fuyao", fp.API_KEY_ENV) == "stor••••••-999" # 注册进插件状态: 数据源列表接口据此常驻展示 (而非仅保存后瞬时显示) loader._register_one_plugin(loader.plugin_manifest("fuyao")) assert loader._PLUGIN_STATUS["fuyao"]["api_key_masked"] == "stor••••••-999" # ---- 设置页试拉 ---- def test_test_dataset_realtime_preview(monkeypatch): provider, _ = _provider_with(monkeypatch, [[_row(), _row("000001.SZ")]], count=5400) out = provider.test_dataset("realtime") assert out["provider"] == "fuyao" assert out["rows"] == 5400 assert out["preview"][0]["symbol"] == "600519.SH" assert out["preview"][0]["change_pct"] == pytest.approx(0.0172) def test_test_dataset_unsupported_dataset_reports_fallback(monkeypatch): provider, _ = _provider_with(monkeypatch, [[]]) out = provider.test_dataset("minute") assert "error" in out and "回退" in out["error"] def test_close_is_idempotent(monkeypatch): provider, _ = _provider_with(monkeypatch, [[_row()]]) provider.close() provider.close() # ===================================================================== # daily / adj_factor (2026-08 接入) # ===================================================================== def _sh_ms(d: date) -> int: """交易日 → 扶摇口径 ms(该日上海零点), 与 provider._ms_of_date 同式。""" return (calendar.timegm(d.timetuple()) - 28_800) * 1000 def _bar(d: date, close: float, volume: float = 1_612_611, open_: float | None = None): """historical/dump 原始行(价格元, volume 股)。""" return { "date_ms": _sh_ms(d), "open_price": open_ if open_ is not None else round(close * 0.99, 2), "high_price": round(close * 1.01, 2), "low_price": round(close * 0.98, 2), "close_price": close, "volume": volume, "turnover": close * volume, } def _dump_bar(sym: str, d: date, close: float) -> dict: """daily dump 行(11 列形状, 含 thscode/adjusted)。""" return { "thscode": sym, "currency": "CNY", "interval": "1d", "adjusted": "none", **_bar(d, close), } class _FakeHistClient: """单标的历史K线假客户端: 按窗口过滤预置 bars, 记录调用参数。""" def __init__(self, bars_by_symbol: dict | None = None, error_syms: tuple = ()): self.bars = bars_by_symbol or {} self.errors = set(error_syms) self.calls: list[dict] = [] def historical_kline(self, thscode, start_ms, end_ms, adjust="none"): self.calls.append({"thscode": thscode, "start": start_ms, "end": end_ms, "adjust": adjust}) if thscode in self.errors: raise fc.FuyaoError("扶摇接口错误 code=4001: 频率超限") return [b for b in self.bars.get(thscode, []) if start_ms <= b["date_ms"] <= end_ms] def dump_download_url(self, dump_kind): raise fc.FuyaoError("测试环境无 dump") def close(self): pass def _hist_provider(monkeypatch, fake: _FakeHistClient, allow_dumps: bool = False) -> FuyaoProvider: p = FuyaoProvider() monkeypatch.setattr(fp, "fuyao_client", type("M", (), {"FuyaoClient": lambda **kw: fake})) monkeypatch.setattr(fp, "get_api_key", lambda: "test-key") monkeypatch.setattr(fp, "_HIST_INTERVAL_S", 0.0) if not allow_dumps: # 默认禁用 dump 档(单标的接口路径测试用); 大 dump 测试传 allow_dumps=True p._ensure_daily_big_dump = lambda start_d: None # type: ignore[assignment] return p def _daily10_dump(rows: list[dict]) -> pl.DataFrame: """11 列 daily-k-10d dump 形状。""" return pl.DataFrame( rows, schema={ "thscode": pl.String, "currency": pl.String, "interval": pl.String, "adjusted": pl.String, "date_ms": pl.Int64, "open_price": pl.Float64, "high_price": pl.Float64, "low_price": pl.Float64, "close_price": pl.Float64, "volume": pl.Float64, "turnover": pl.Float64, }, ) def _adj_dump(events: list[tuple]) -> pl.DataFrame: """8 列 adjustment-factors dump 形状。events: (thscode, ex_date, D, S, AR, AP)。""" rows = [ { "thscode": s, "ticker": s.split(".")[0], "ex_date_ms": _sh_ms(d), "dividend_per_share": dv, "per_share_bonus": bn, "allotment_ratio": ar, "allotment_price": ap, "currency": "CNY", } for s, d, dv, bn, ar, ap in events ] return pl.DataFrame( rows, schema={ "thscode": pl.String, "ticker": pl.String, "ex_date_ms": pl.Int64, "dividend_per_share": pl.Float64, "per_share_bonus": pl.Float64, "allotment_ratio": pl.Float64, "allotment_price": pl.Float64, "currency": pl.String, }, ) def _adj_provider(monkeypatch, events: list[tuple], bars_by_symbol: dict) -> FuyaoProvider: p = _hist_provider(monkeypatch, _FakeHistClient(bars_by_symbol)) p._dump_memo[fp._ADJ_DUMP_KIND] = _adj_dump(events) return p # ---- daily: 单标的接口路径 ---- def test_daily_api_units_volume_shares_to_lots(monkeypatch): """核心口径: 原始价 + volume 股→手 floor(/100) + 上海零点时区。""" bars = [ _bar(date(2026, 8, 26), 11.10), _bar(date(2026, 8, 27), 11.05), _bar(date(2026, 8, 28), 11.65), ] provider = _hist_provider(monkeypatch, _FakeHistClient({"000001.SZ": bars})) # 窗口跨度 28 天 > _RECENT_DUMP_DAYS → 走单标的接口 df = provider.get_daily(["000001.SZ"], datetime(2026, 8, 1), datetime(2026, 8, 28)) assert df.height == 3 assert df.columns == ["symbol", "date", "open", "high", "low", "close", "volume", "amount"] assert df.schema["date"] == pl.Date assert df["date"].to_list() == [date(2026, 8, 26), date(2026, 8, 27), date(2026, 8, 28)] assert df["volume"].to_list() == [16126.0] * 3 # 1,612,611 股 → 16126 手 assert df["close"].to_list() == [11.10, 11.05, 11.65] assert df["amount"].to_list() == [b["turnover"] for b in bars] def test_daily_api_adjust_locked_to_none(monkeypatch): """adjust=none 锁定: 服务端默认 forward, 官方前复权序列不可用(事件间逐日漂移)。""" provider = _hist_provider( monkeypatch, _FakeHistClient({"000001.SZ": [_bar(date(2026, 8, 27), 11.05)]}) ) provider.get_daily(["000001.SZ"], datetime(2026, 8, 1), datetime(2026, 8, 28)) fake = provider._get_client() assert fake.calls and all(c["adjust"] == "none" for c in fake.calls) def test_daily_api_deep_window_splits_into_10y_chunks(monkeypatch): """超 10 年窗口自动分片(25.6 年 → 3 片), 分片连续不重叠且各 ≤10 年。""" provider = _hist_provider(monkeypatch, _FakeHistClient({"000001.SZ": []})) provider.get_daily(["000001.SZ"], datetime(2001, 1, 1), datetime(2026, 8, 28)) calls = provider._get_client().calls assert len(calls) == 3 for c in calls: assert c["end"] - c["start"] < fp._HIST_MAX_SPAN_MS for a, b in itertools.pairwise(calls): assert b["start"] == a["end"] + 1 # 连续且不重叠 def test_daily_api_soft_fail_per_symbol(monkeypatch): """单标的失败软跳过, 不阻断其余标的。""" bars = {"000001.SZ": [_bar(date(2026, 8, 27), 11.05)]} provider = _hist_provider(monkeypatch, _FakeHistClient(bars, error_syms=("600519.SH",))) df = provider.get_daily(["600519.SH", "000001.SZ"], datetime(2026, 8, 1), datetime(2026, 8, 28)) assert df["symbol"].unique().to_list() == ["000001.SZ"] def test_daily_empty_symbols_or_non_stock_returns_empty(monkeypatch): provider = _hist_provider(monkeypatch, _FakeHistClient({})) assert provider.get_daily([], None, None).is_empty() assert provider.get_daily( ["510300.SH"], datetime(2026, 8, 1), datetime(2026, 8, 28), asset_type="etf" ).is_empty() # ---- daily: 10d dump 路径 ---- def _recent_daily_dump() -> pl.DataFrame: rows = [] for d in [date(2026, 8, 25), date(2026, 8, 26), date(2026, 8, 27), date(2026, 8, 28)]: b = _bar(d, 11.0 + hash(d) % 3, volume=97_570_170) rows.append( {"thscode": "000001.SZ", "currency": "CNY", "interval": "1d", "adjusted": "none", **b} ) rows.append( { "thscode": "600519.SH", "currency": "CNY", "interval": "1d", "adjusted": "none", **_bar(d, 1480.0, volume=1_234_500), } ) # 停牌行(open=high=0)应被过滤 rows.append( { "thscode": "000001.SZ", "currency": "CNY", "interval": "1d", "adjusted": "none", **_bar(date(2026, 8, 27), 11.0, open_=0.0), } ) rows[-1]["high_price"] = 0.0 return _daily10_dump(rows) def test_daily_recent_window_uses_dump_without_api_calls(monkeypatch): """近端窗口: 一次 dump 覆盖全部标的, 不打单标的接口; volume 股→手; 停牌行过滤。""" provider = _hist_provider(monkeypatch, _FakeHistClient({})) provider._dump_memo[fp._DAILY10_DUMP_KIND] = _recent_daily_dump() progress = [] df = provider.get_daily( ["000001.SZ"], datetime(2026, 8, 26), datetime(2026, 8, 28), on_chunk_done=lambda c, t: progress.append((c, t)), ) assert provider._get_client().calls == [] # 未走单标的接口 assert progress == [(1, 1)] assert set(df["symbol"].to_list()) == {"000001.SZ"} # 未请求的标的不混入 assert df["volume"].max() == 975_701.0 # 97,570,170 股 → 975,701 手 assert date(2026, 8, 26) in df["date"].to_list() # 停牌行(open=high=0)被 filter_halt_days 剔除, 08-27 仅保留正常行 assert df.filter(pl.col("date") == date(2026, 8, 27)).height == 1 def test_daily_dump_stale_falls_back_to_api(monkeypatch): """dump 末端落后且 end 是工作日 → 视为有缺口, 回退单标的接口。""" bars = {"000001.SZ": [_bar(date(2026, 8, 27), 11.05)]} provider = _hist_provider(monkeypatch, _FakeHistClient(bars)) provider._dump_memo[fp._DAILY10_DUMP_KIND] = _recent_daily_dump() # max=08-28 provider.get_daily(["000001.SZ"], datetime(2026, 8, 24), datetime(2026, 9, 4)) # 09-04 周五 assert provider._get_client().calls != [] def test_daily_dump_weekend_end_covered(monkeypatch): """end 为周末且紧随 dump 末端 → 自然缺口, 仍走 dump。""" provider = _hist_provider(monkeypatch, _FakeHistClient({})) provider._dump_memo[fp._DAILY10_DUMP_KIND] = _recent_daily_dump() # max=周五 08-28 df = provider.get_daily(["000001.SZ"], datetime(2026, 8, 26), datetime(2026, 8, 30)) # 周日 assert provider._get_client().calls == [] assert not df.is_empty() def test_daily_dump_rejects_non_raw_adjustment(monkeypatch): """防御: dump 变为复权口径(adjusted != none)时拒绝输出, 不污染原始K线库。""" dump = _recent_daily_dump().with_columns(pl.lit("forward").alias("adjusted")) provider = _hist_provider(monkeypatch, _FakeHistClient({})) provider._dump_memo[fp._DAILY10_DUMP_KIND] = dump assert provider.get_daily( ["000001.SZ"], datetime(2026, 8, 26), datetime(2026, 8, 28) ).is_empty() def test_daily_old_window_bypasses_dump(monkeypatch): """深窗口且 dump 不可用 → 单标的接口兜底。""" provider = _hist_provider(monkeypatch, _FakeHistClient({"000001.SZ": []})) df = provider.get_daily(["000001.SZ"], datetime(2020, 1, 1), datetime(2026, 8, 28)) assert df.is_empty() assert provider._get_client().calls # 走了单标的接口 # ---- daily: 10 年全量 dump 档 ---- def _bigdump_provider( monkeypatch, tmp_path, big_rows: list[dict], ten_memo: pl.DataFrame | None = None ): """缓存目录重定向到 tmp_path 并放置 daily_k__.parquet; 单标的接口若被调用会被断言暴露。""" provider = _hist_provider(monkeypatch, _FakeHistClient({}), allow_dumps=True) monkeypatch.setattr(fp, "_cache_dir", lambda: tmp_path) _daily10_dump(big_rows).write_parquet(tmp_path / "daily_k__20260101.parquet") if ten_memo is not None: provider._dump_memo[fp._DAILY10_DUMP_KIND] = ten_memo return provider def test_daily_deep_window_uses_big_dump(monkeypatch, tmp_path): """深窗口走 10 年 dump: 不打单标的接口, volume 股→手, 未请求标的不混入。""" rows = [ { "thscode": "000001.SZ", "currency": "CNY", "interval": "1d", "adjusted": "none", **_bar(date(2026, 1, 5), 11.0, volume=97_570_170), }, { "thscode": "000001.SZ", "currency": "CNY", "interval": "1d", "adjusted": "none", **_bar(date(2026, 6, 12), 11.7, volume=1_234_500), }, { "thscode": "000001.SZ", "currency": "CNY", "interval": "1d", "adjusted": "none", **_bar(date(2026, 8, 14), 11.4, volume=1_234_500), }, { "thscode": "600519.SH", "currency": "CNY", "interval": "1d", "adjusted": "none", **_bar(date(2026, 8, 14), 1480.0, volume=1_234_500), }, ] provider = _bigdump_provider(monkeypatch, tmp_path, rows) progress = [] df = provider.get_daily( ["000001.SZ"], datetime(2026, 1, 5), datetime(2026, 8, 14), on_chunk_done=lambda c, t: progress.append((c, t)), ) assert provider._get_client().calls == [] assert progress == [(1, 1)] assert set(df["symbol"].to_list()) == {"000001.SZ"} assert df["date"].to_list() == [date(2026, 1, 5), date(2026, 6, 12), date(2026, 8, 14)] assert df["volume"].to_list() == [975_701.0, 12_345.0, 12_345.0] def test_daily_big_dump_tail_filled_by_10d(monkeypatch, tmp_path): """大 dump 末端缺口(dmax 旧)由 10d dump 补尾, 两段拼接无缝。""" big_rows = [ { "thscode": "000001.SZ", "currency": "CNY", "interval": "1d", "adjusted": "none", **_bar(date(2026, 8, 10), 10.9), }, { "thscode": "000001.SZ", "currency": "CNY", "interval": "1d", "adjusted": "none", **_bar(date(2026, 8, 14), 11.0), }, ] ten_rows = [ { "thscode": "000001.SZ", "currency": "CNY", "interval": "1d", "adjusted": "none", **_bar(date(2026, 8, 15), 11.2), }, { "thscode": "000001.SZ", "currency": "CNY", "interval": "1d", "adjusted": "none", **_bar(date(2026, 8, 28), 11.65), }, ] provider = _bigdump_provider(monkeypatch, tmp_path, big_rows, ten_memo=_daily10_dump(ten_rows)) df = provider.get_daily(["000001.SZ"], datetime(2026, 8, 10), datetime(2026, 8, 28)) assert df["date"].to_list() == [ date(2026, 8, 10), date(2026, 8, 14), date(2026, 8, 15), date(2026, 8, 28), ] assert df["close"].to_list() == [10.9, 11.0, 11.2, 11.65] def test_daily_big_dump_midgap_falls_back(monkeypatch, tmp_path): """大 dump 与 10d dump 之间有中段缺口 → 整体回退, 不拼缺口数据。""" big_rows = [ { "thscode": "000001.SZ", "currency": "CNY", "interval": "1d", "adjusted": "none", **_bar(date(2026, 8, 1), 10.9), }, { "thscode": "000001.SZ", "currency": "CNY", "interval": "1d", "adjusted": "none", **_bar(date(2026, 8, 5), 11.0), }, ] # 10d 从 08-27 起, 与大 dump 末端 08-05 之间有中段缺口 ten_rows = [ { "thscode": "000001.SZ", "currency": "CNY", "interval": "1d", "adjusted": "none", **_bar(date(2026, 8, 27), 11.5), }, ] provider = _bigdump_provider(monkeypatch, tmp_path, big_rows, ten_memo=_daily10_dump(ten_rows)) df = provider.get_daily(["000001.SZ"], datetime(2026, 8, 1), datetime(2026, 8, 28)) assert df.is_empty() # 兜底走单标的接口(fake 为空) → 不返回缺口拼接数据 def test_daily_big_dump_start_before_dmin_falls_back(monkeypatch, tmp_path): """窗口早于大 dump 起点(>10 年) → 单标的接口兜底。""" rows = [ { "thscode": "000001.SZ", "currency": "CNY", "interval": "1d", "adjusted": "none", **_bar(date(2020, 1, 2), 10.0), }, ] provider = _bigdump_provider(monkeypatch, tmp_path, rows) # 缓存不覆盖起点 → 会尝试拉最新 release; 模拟"最新 release 就是这份缓存文件" provider._ensure_dump_path = lambda kind, prefix: tmp_path / "daily_k__20260101.parquet" # type: ignore[assignment] bars = {"000001.SZ": [_bar(date(2015, 6, 1), 9.0)]} provider._dump_memo.clear() monkeypatch.setattr( fp, "fuyao_client", type("M", (), {"FuyaoClient": lambda **kw: _FakeHistClient(bars)}) ) df = provider.get_daily(["000001.SZ"], datetime(2015, 1, 1), datetime(2015, 12, 31)) assert df.height == 1 and df["close"][0] == 9.0 # 单标的兜底成功 def test_tail_ok_weekend_tolerance(): """末端容忍: 周末/节假日的自然缺口(≤3 天且 end 是周末)不算缺失。""" assert fp._tail_ok(date(2026, 8, 28), date(2026, 8, 28)) is True assert fp._tail_ok(date(2026, 8, 29), date(2026, 8, 28)) is True # 周六 assert fp._tail_ok(date(2026, 8, 30), date(2026, 8, 28)) is True # 周日 assert fp._tail_ok(date(2026, 8, 31), date(2026, 8, 28)) is False # 周一(可能有行情) assert fp._tail_ok(date(2026, 9, 5), date(2026, 8, 28)) is False # 隔了一周 # ---- adj_factor: 推导 ---- def test_adj_dividend_factor_derivation(monkeypatch): """纯分红: P=32.8, D=0.68 → ref=32.12 → factor=32.8/32.12 (与 tickflow 对拍值一致)。""" events = [("600519.SH", date(2026, 6, 12), 0.68, 0.0, 0.0, 0.0)] bars = {"600519.SH": [_bar(date(2026, 6, 11), 32.8), _bar(date(2026, 6, 12), 32.0)]} provider = _adj_provider(monkeypatch, events, bars) df = provider.get_adj_factors(["600519.SH"], datetime(2026, 1, 1), datetime(2026, 12, 31)) assert df.height == 1 row = df.row(0, named=True) assert row["trade_date"] == date(2026, 6, 12) assert row["ex_factor"] == pytest.approx(32.8 / 32.12) def test_adj_bonus_and_allotment_formula(monkeypatch): """送转+配股混合: P=5.23, AR=0.4, AP=3.36 → ref=(5.23+1.344)/1.4=4.6957→4.70(half-up)。""" events = [("300176.SZ", date(2026, 8, 21), 0.0, 0.0, 0.4, 3.36)] bars = {"300176.SZ": [_bar(date(2026, 8, 20), 5.23), _bar(date(2026, 8, 21), 4.68)]} provider = _adj_provider(monkeypatch, events, bars) df = provider.get_adj_factors(["300176.SZ"], datetime(2026, 1, 1), datetime(2026, 12, 31)) assert df.height == 1 assert df["ex_factor"][0] == pytest.approx(5.23 / 4.70) def test_adj_half_up_rounding_not_bankers(monkeypatch): """舍入口径: x=2.625 → half-up 2.63 (银行家舍入会给 2.62, 对拍实证偏离)。""" events = [("600000.SH", date(2026, 6, 12), 8.0, 0.0, 0.0, 0.0)] bars = {"600000.SH": [_bar(date(2026, 6, 11), 10.625), _bar(date(2026, 6, 12), 2.70)]} provider = _adj_provider(monkeypatch, events, bars) df = provider.get_adj_factors(["600000.SH"], datetime(2026, 1, 1), datetime(2026, 12, 31)) assert df["ex_factor"][0] == pytest.approx(10.625 / 2.63) def test_adj_price_limit_selfcheck_drops_impossible_factor(monkeypatch): """涨跌停自检: 复权后除权日涨幅超板限的事件剔除, 不让坏因子落库。""" events = [("600000.SH", date(2026, 6, 12), 8.0, 0.0, 0.0, 0.0)] bars = {"600000.SH": [_bar(date(2026, 6, 11), 10.625), _bar(date(2026, 6, 12), 4.05)]} # +54% provider = _adj_provider(monkeypatch, events, bars) df = provider.get_adj_factors(["600000.SH"], datetime(2026, 1, 1), datetime(2026, 12, 31)) assert df.is_empty() def test_adj_merges_same_day_components_before_derivation(monkeypatch): """同日拆行(分红行+送股行)必须先合并再推导。""" events = [ ("000812.SZ", date(2026, 6, 12), 0.3, 0.0, 0.0, 0.0), ("000812.SZ", date(2026, 6, 12), 0.0, 0.1, 0.0, 0.0), ] bars = {"000812.SZ": [_bar(date(2026, 6, 11), 10.0), _bar(date(2026, 6, 12), 9.0)]} provider = _adj_provider(monkeypatch, events, bars) df = provider.get_adj_factors(["000812.SZ"], datetime(2026, 1, 1), datetime(2026, 12, 31)) assert df.height == 1 # 合并成一个事件 assert df["ex_factor"][0] == pytest.approx(10.0 / 8.82) # ref=(10-0.3)/1.1=8.8181→8.82 def test_adj_filters_zero_rows_and_missing_allot_price(monkeypatch): """全零行与配股价缺失行过滤, 不参与推导。""" events = [ ("000001.SZ", date(2026, 6, 12), 0.0, 0.0, 0.0, 0.0), # 全零 ("000002.SZ", date(2026, 6, 12), 0.0, 0.0, 0.3, 0.0), # 配股无价 ("600519.SH", date(2026, 6, 12), 0.68, 0.0, 0.0, 0.0), # 正常 ] bars = { "600519.SH": [_bar(date(2026, 6, 11), 32.8), _bar(date(2026, 6, 12), 32.0)], "000001.SZ": [_bar(date(2026, 6, 11), 10.0), _bar(date(2026, 6, 12), 10.0)], "000002.SZ": [_bar(date(2026, 6, 11), 8.0), _bar(date(2026, 6, 12), 8.0)], } provider = _adj_provider(monkeypatch, events, bars) df = provider.get_adj_factors(["000001.SZ", "000002.SZ", "600519.SH"], None, None) assert df["symbol"].unique().to_list() == ["600519.SH"] def test_adj_skips_future_announced_events(monkeypatch): """未来已公告事件无前收盘, 跳过留给滚动增量窗口。""" future = date.today() + timedelta(days=5) events = [("600519.SH", future, 0.68, 0.0, 0.0, 0.0)] provider = _adj_provider(monkeypatch, events, {"600519.SH": []}) df = provider.get_adj_factors(["600519.SH"], datetime(2026, 1, 1), None) assert df.is_empty() def test_adj_window_filter(monkeypatch): events = [ ("600519.SH", date(2026, 1, 10), 0.5, 0.0, 0.0, 0.0), ("600519.SH", date(2026, 6, 12), 0.68, 0.0, 0.0, 0.0), ] bars = { "600519.SH": [ _bar(date(2026, 1, 9), 30.0), _bar(date(2026, 1, 10), 30.0), _bar(date(2026, 6, 11), 32.8), _bar(date(2026, 6, 12), 32.0), ] } provider = _adj_provider(monkeypatch, events, bars) df = provider.get_adj_factors(["600519.SH"], datetime(2026, 3, 1), datetime(2026, 12, 31)) assert df["trade_date"].to_list() == [date(2026, 6, 12)] def test_adj_output_schema_sorted_and_deduped(monkeypatch): events = [ ("000001.SZ", date(2026, 6, 12), 0.36, 0.0, 0.0, 0.0), ("600519.SH", date(2026, 6, 12), 0.68, 0.0, 0.0, 0.0), ("600519.SH", date(2025, 6, 20), 0.5, 0.0, 0.0, 0.0), ] bars = { "600519.SH": [ _bar(date(2025, 6, 19), 31.0), _bar(date(2025, 6, 20), 31.0), _bar(date(2026, 6, 11), 32.8), _bar(date(2026, 6, 12), 32.0), ], "000001.SZ": [_bar(date(2026, 6, 11), 11.7), _bar(date(2026, 6, 12), 11.5)], } provider = _adj_provider(monkeypatch, events, bars) df = provider.get_adj_factors(["000001.SZ", "600519.SH"], None, None) assert df.columns == ["symbol", "trade_date", "ex_factor"] assert df.schema["trade_date"] == pl.Date and df.schema["ex_factor"] == pl.Float64 assert df.height == 3 assert df.sort(["symbol", "trade_date"]).equals(df) # 已排序 assert df["ex_factor"].min() > 1.0 # 分红因子必 >1 def test_adj_etf_and_empty_symbols_return_empty(monkeypatch): provider = _adj_provider(monkeypatch, [], {}) empty = provider.get_adj_factors([], None, None) assert empty.is_empty() and empty.columns == ["symbol", "trade_date", "ex_factor"] etf = provider.get_adj_factors(["510300.SH"], None, None, asset_type="etf") assert etf.is_empty() def test_adj_dump_unavailable_returns_empty(monkeypatch): """dump 加载失败软返回空(不阻断管道), 不抛异常。""" provider = _hist_provider(monkeypatch, _FakeHistClient({})) def _boom(dump_kind, cache_prefix): raise fc.FuyaoError("dump 下载网络失败") provider._ensure_dump = _boom # type: ignore[assignment] df = provider.get_adj_factors(["600519.SH"], None, None) assert df.is_empty() and df.columns == ["symbol", "trade_date", "ex_factor"] def test_adj_progress_callback_per_symbol(monkeypatch): events = [ ("000001.SZ", date(2026, 6, 12), 0.36, 0.0, 0.0, 0.0), ("600519.SH", date(2026, 6, 12), 0.68, 0.0, 0.0, 0.0), ] bars = { "600519.SH": [_bar(date(2026, 6, 11), 32.8), _bar(date(2026, 6, 12), 32.0)], "000001.SZ": [_bar(date(2026, 6, 11), 11.7), _bar(date(2026, 6, 12), 11.5)], } provider = _adj_provider(monkeypatch, events, bars) progress = [] provider.get_adj_factors( ["600519.SH", "000001.SZ"], None, None, on_chunk_done=lambda c, t: progress.append((c, t)) ) assert progress == [(1, 2), (2, 2)] # ---- adj_factor: 本地 dump 配价(2026-08 优化) ---- def test_adj_closes_from_local_dump_no_http(monkeypatch, tmp_path): """前收盘从本地日K dump 一次取齐: 零单标的请求, 因子值与接口路径同公式。""" events = [("000001.SZ", date(2026, 6, 12), 0.3, 0.0, 0.0, 0.0)] big_rows = [ _dump_bar("000001.SZ", date(2026, 5, 10), 10.2), # 覆盖窗口起点(first_ex-30d) _dump_bar("000001.SZ", date(2026, 6, 11), 10.625), _dump_bar("000001.SZ", date(2026, 6, 12), 9.6), ] provider = _bigdump_provider(monkeypatch, tmp_path, big_rows) provider._dump_memo[fp._ADJ_DUMP_KIND] = _adj_dump(events) progress = [] df = provider.get_adj_factors( ["000001.SZ"], datetime(2026, 1, 1), datetime(2026, 12, 31), on_chunk_done=lambda c, t: progress.append((c, t)), ) assert provider._get_client().calls == [] # 零 HTTP assert progress == [(1, 1)] assert df.height == 1 assert df["trade_date"][0] == date(2026, 6, 12) assert df["ex_factor"][0] == pytest.approx(10.625 / fp._ref_price(10.625, 0.3, 0.0, 0.0, 0.0)) def test_adj_missing_symbol_in_dump_falls_back_to_http(monkeypatch, tmp_path): """dump 缺价的标的回退单标的接口; dump 内标的仍零请求。""" events = [ ("000001.SZ", date(2026, 6, 12), 0.3, 0.0, 0.0, 0.0), ("600519.SH", date(2026, 6, 12), 1.0, 0.0, 0.0, 0.0), ] big_rows = [ _dump_bar("000001.SZ", date(2026, 5, 10), 10.2), _dump_bar("000001.SZ", date(2026, 6, 11), 10.625), _dump_bar("000001.SZ", date(2026, 6, 12), 9.6), ] provider = _bigdump_provider(monkeypatch, tmp_path, big_rows) provider._dump_memo[fp._ADJ_DUMP_KIND] = _adj_dump(events) provider._get_client().bars["600519.SH"] = [ _bar(date(2026, 6, 11), 1500.0), _bar(date(2026, 6, 12), 1400.0), ] df = provider.get_adj_factors( ["000001.SZ", "600519.SH"], datetime(2026, 1, 1), datetime(2026, 12, 31) ) assert [c["thscode"] for c in provider._get_client().calls] == ["600519.SH"] assert set(df["symbol"].to_list()) == {"000001.SZ", "600519.SH"} def test_adj_ten_day_dump_overlays_fresh_ex_close(monkeypatch, tmp_path): """大 dump 缺除权日收盘(发布滞后)时由 10d dump 叠加补, 涨跌停自检仍生效。""" events = [("000001.SZ", date(2026, 8, 28), 8.0, 0.0, 0.0, 0.0)] # 异常大额分红 big_rows = [ _dump_bar("000001.SZ", date(2026, 7, 25), 10.0), # 覆盖窗口起点 _dump_bar("000001.SZ", date(2026, 8, 27), 10.625), # 大 dump 只到除权前一日 ] ten_rows = [_dump_bar("000001.SZ", date(2026, 8, 28), 4.05)] # +54% 超涨跌停 provider = _bigdump_provider(monkeypatch, tmp_path, big_rows, ten_memo=_daily10_dump(ten_rows)) provider._dump_memo[fp._ADJ_DUMP_KIND] = _adj_dump(events) df = provider.get_adj_factors(["000001.SZ"], datetime(2026, 1, 1), datetime(2026, 12, 31)) assert df.is_empty() # 自检剔除(无叠加时 ex 日收盘缺失, 该行会被保留) def test_adj_local_pricing_corrupt_dump_falls_back(monkeypatch, tmp_path): """本地配价读盘异常不致命: 整轮回退单标的接口。""" events = [("000001.SZ", date(2026, 6, 12), 0.3, 0.0, 0.0, 0.0)] big_rows = [ _dump_bar("000001.SZ", date(2026, 5, 10), 10.2), _dump_bar("000001.SZ", date(2026, 6, 11), 10.625), _dump_bar("000001.SZ", date(2026, 6, 12), 9.6), ] provider = _bigdump_provider(monkeypatch, tmp_path, big_rows) provider._dump_memo[fp._ADJ_DUMP_KIND] = _adj_dump(events) def _boom(_lf): # 签名对齐被替换的 _closes_scan raise OSError("disk error") provider._closes_scan = _boom # type: ignore[assignment] provider._get_client().bars["000001.SZ"] = [ _bar(date(2026, 6, 11), 10.625), _bar(date(2026, 6, 12), 9.6), ] df = provider.get_adj_factors(["000001.SZ"], datetime(2026, 1, 1), datetime(2026, 12, 31)) assert df.height == 1 assert provider._get_client().calls # 回退了接口 # ---- 客户端: 历史日K / dump ---- class _RecordingHttp: def __init__(self, payload): self.payload = payload self.calls: list[tuple] = [] def get(self, path, params=None): self.calls.append((path, params)) resp = type("R", (), {"json": lambda self_: self.payload, "status_code": 200})() return resp def close(self): pass def test_client_historical_kline_sends_params(monkeypatch): http = _RecordingHttp({"code": 0, "data": {"item": [_bar(date(2026, 8, 28), 11.65)]}}) monkeypatch.setattr(fc.httpx, "Client", lambda **kw: http) c = fc.FuyaoClient(api_key="k") rows = c.historical_kline("000001.SZ", 1, 2, adjust="none") assert len(rows) == 1 path, params = http.calls[0] assert path == "/api/a-share/prices/historical" assert params == { "thscode": "000001.SZ", "interval": "1d", "adjust": "none", "start": 1, "end": 2, } def test_client_historical_kline_missing_item(monkeypatch): http = _RecordingHttp({"code": 0, "data": {}}) monkeypatch.setattr(fc.httpx, "Client", lambda **kw: http) c = fc.FuyaoClient(api_key="k") assert c.historical_kline("000001.SZ", 1, 2) == [] def test_client_dump_download_url(monkeypatch): info = { "presigned_url": "https://o.thsi.cn/.../releases/20260828/x.parquet?sig=1", "presigned_url_expires_at": "2026-08-29T12:00:00+08:00", "expires_in_seconds": 300, } http = _RecordingHttp({"code": 0, "data": info}) monkeypatch.setattr(fc.httpx, "Client", lambda **kw: http) c = fc.FuyaoClient(api_key="k") assert c.dump_download_url("adjustment-factors") == info assert http.calls[0][0] == "/api/dump/market-dumps/adjustment-factors/download-url" def test_client_download_dump_atomic_and_key_never_leaked(monkeypatch, tmp_path): """/S3 预签名下载不带 X-api-key; 成功后原子改名, .part 清理。""" http = _RecordingHttp( { "code": 0, "data": { "presigned_url": "https://o.thsi.cn/fuyao/market-dump/adj/releases/20260828/a.parquet?sig=1" }, } ) monkeypatch.setattr(fc.httpx, "Client", lambda **kw: http) captured: dict = {} class _Resp: status_code = 200 def iter_bytes(self, n): yield b"parquet-bytes-" class _Ctx: def __enter__(self): return _Resp() def __exit__(self, *a): return False def _stream(method, url, **kw): captured["method"], captured["url"], captured["kw"] = method, url, kw return _Ctx() monkeypatch.setattr(fc.httpx, "stream", _stream) dest = tmp_path / "adj__20260828.parquet" fc.FuyaoClient(api_key="k").download_dump("adjustment-factors", dest) assert captured["method"] == "GET" and "o.thsi.cn" in captured["url"] assert "headers" not in captured["kw"] or "X-api-key" not in ( captured["kw"].get("headers") or {} ) assert dest.read_bytes() == b"parquet-bytes-" assert not list(tmp_path.glob("*.part")) def test_client_download_dump_http_error(monkeypatch, tmp_path): http = _RecordingHttp({"code": 0, "data": {"presigned_url": "https://o.thsi.cn/x.parquet"}}) monkeypatch.setattr(fc.httpx, "Client", lambda **kw: http) class _Resp: status_code = 403 class _Ctx: def __enter__(self): return _Resp() def __exit__(self, *a): return False monkeypatch.setattr(fc.httpx, "stream", lambda m, u, **kw: _Ctx()) dest = tmp_path / "x.parquet" with pytest.raises(fc.FuyaoError, match="403"): fc.FuyaoClient(api_key="k").download_dump("adjustment-factors", dest) assert not dest.exists() and not list(tmp_path.glob("*.part")) # ---- 时区与 release 解析 ---- def test_shanghai_midnight_ms_roundtrip(): """上海零点戳 ↔ 日期: +8h 换算不得偏移一天。""" d = date(2026, 6, 12) assert fp._date_of_ms(_sh_ms(d)) == d assert fp._ms_of_date(d) == _sh_ms(d) def test_release_of_extracts_from_presigned_url(): url = "https://o.thsi.cn/x/market-dump/adj/releases/20260828/a.parquet?sig=1" assert fp._release_of(url) == "20260828" assert fp._release_of("https://no-release-here/x") == "unknown" # ---- 设置页试拉: daily / adj_factor ---- def test_test_dataset_daily_preview(monkeypatch): bars = {"000001.SZ": [_bar(date(2026, 8, 27), 11.05), _bar(date(2026, 8, 28), 11.65)]} provider = _hist_provider(monkeypatch, _FakeHistClient(bars)) out = provider.test_dataset("daily", ["000001.SZ"]) assert out["provider"] == "fuyao" and out["dataset"] == "daily" assert out["rows"] == 2 assert out["preview"][0]["date"] == "2026-08-27" # date → ISO 字符串 def test_test_dataset_adj_factor_preview(monkeypatch): events = [("600519.SH", date(2026, 6, 12), 0.68, 0.0, 0.0, 0.0)] bars = {"600519.SH": [_bar(date(2026, 6, 11), 32.8), _bar(date(2026, 6, 12), 32.0)]} provider = _adj_provider(monkeypatch, events, bars) out = provider.test_dataset("adj_factor", ["600519.SH"]) assert out["rows"] == 1 assert out["preview"][0]["trade_date"] == "2026-06-12" assert out["preview"][0]["ex_factor"] == pytest.approx(32.8 / 32.12)