mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
- 财务接入(股本除外): 三大报表多期 + 指标单期, 字段映射至项目 canonical 列 - metrics: bps 由估值 pb_mrq 反推, eps_basic 取自利润表, report 期由最新披露期反查 - financial_sync 报告期合并升级为逐列填空: 多源数据并集共存, 新值覆盖缺列保留 - 前序未入库工作一并提交: 10 年/10d 日K dump、除权因子事件推导与涨跌停自检
1355 lines
51 KiB
Python
1355 lines
51 KiB
Python
"""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__<release>.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)
|