Files
chinese-equity-quant/tests/test_downloader_contracts.py
Yuxuan Yan 528620b271 Raise coverage threshold to 95% and expand test coverage
- pyproject.toml: fail_under 80 → 95
- test_alpha: +79 lines
- test_cli_workflow: +226 lines
- test_derived: +121 lines
- test_downloader_contracts: +169 lines
- test_features: +16 lines
- test_minute_downloader: +81 lines
- test_portfolio: +208 lines
2026-06-16 21:10:30 +08:00

632 lines
18 KiB
Python

"""Offline downloader contract tests with mocked data providers."""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
import data.downloader as downloader
import pipeline.data.downloader as pipeline_downloader
from data.downloader import download_daily, download_daily_batch
from pipeline.common.schema import DATA_COLUMNS
from pipeline.data.downloader import download_universe
class _FakeResult:
def __init__(self, rows, error_code="0", error_msg=""):
self.rows = rows
self.error_code = error_code
self.error_msg = error_msg
self._idx = -1
def next(self):
self._idx += 1
return self._idx < len(self.rows)
def get_row_data(self):
return self.rows[self._idx]
def _daily_batch_row(
date: str = "2024-01-02",
open_: str = "10",
high: str = "11",
low: str = "9",
close: str = "10.5",
preclose: str = "10",
volume: str = "1000",
amount: str = "10500",
) -> list[str]:
return [
date,
open_,
high,
low,
close,
preclose,
volume,
amount,
"1.23",
"5.0",
"1",
"0",
"8.0",
"1.1",
"2.2",
"3.3",
]
def test_download_daily_uses_baostock_before_akshare_in_auto(monkeypatch):
calls: list[str] = []
expected = pd.DataFrame({
"symbol": ["sh600000"],
"date": ["2024-01-02"],
"open": [10.0],
"high": [11.0],
"low": [9.0],
"close": [10.5],
"volume": [1000.0],
"amount": [10500.0],
})
def fake_baostock(symbol, start, end, adjust):
calls.append("baostock")
return expected
def fake_akshare(symbol, start, end, adjust):
calls.append("akshare")
raise AssertionError("akshare should not be called after baostock succeeds")
monkeypatch.setattr(downloader, "_download_baostock", fake_baostock)
monkeypatch.setattr(downloader, "_download_akshare", fake_akshare)
result = download_daily("sh600000", "2024-01-02", "2024-01-02", source="auto")
assert calls == ["baostock"]
assert result["date"].tolist() == [pd.Timestamp("2024-01-02")]
assert result["close"].tolist() == [10.5]
def test_download_daily_falls_back_to_akshare_when_baostock_empty(monkeypatch):
calls: list[str] = []
fallback = pd.DataFrame({
"symbol": ["sz000001"],
"date": ["2024-01-02"],
"open": [20.0],
"high": [21.0],
"low": [19.0],
"close": [20.5],
"volume": [2000.0],
"amount": [41000.0],
})
monkeypatch.setattr(
downloader,
"_download_baostock",
lambda symbol, start, end, adjust: calls.append("baostock") or None,
)
monkeypatch.setattr(
downloader,
"_download_akshare",
lambda symbol, start, end, adjust: calls.append("akshare") or fallback,
)
result = download_daily("sz000001", "2024-01-02", "2024-01-02", source="auto")
assert calls == ["baostock", "akshare"]
assert result["symbol"].tolist() == ["sz000001"]
assert result["date"].tolist() == [pd.Timestamp("2024-01-02")]
def test_download_daily_raises_when_requested_source_has_no_data(monkeypatch):
monkeypatch.setattr(downloader, "_download_baostock", lambda *args: None)
with pytest.raises(RuntimeError, match="Failed to download data for sh600000"):
download_daily(
"sh600000",
"2024-01-02",
"2024-01-02",
source="baostock",
)
def test_download_daily_akshare_source_skips_baostock(monkeypatch):
calls: list[str] = []
fallback = pd.DataFrame({
"symbol": ["sh600000"],
"date": ["2024-01-02"],
"open": [10.0],
"high": [11.0],
"low": [9.0],
"close": [10.5],
"volume": [1000.0],
"amount": [10500.0],
})
monkeypatch.setattr(
downloader,
"_download_baostock",
lambda *args: calls.append("baostock") or None,
)
monkeypatch.setattr(
downloader,
"_download_akshare",
lambda *args: calls.append("akshare") or fallback,
)
result = download_daily(
"sh600000",
"2024-01-02",
"2024-01-02",
source="akshare",
)
assert calls == ["akshare"]
assert result["date"].tolist() == [pd.Timestamp("2024-01-02")]
def test_akshare_daily_downloader_maps_columns_and_failures(monkeypatch):
calls: list[dict] = []
raw = pd.DataFrame({
"日期": ["2024-01-02"],
"开盘": [10.0],
"最高": [11.0],
"最低": [9.0],
"收盘": [10.5],
"成交量": [1000.0],
"成交额": [10500.0],
"换手率": [1.2],
})
def fake_hist(**kwargs):
calls.append(kwargs)
return raw.copy()
monkeypatch.setattr(downloader.ak, "stock_zh_a_hist", fake_hist)
result = downloader._download_akshare(
"sh600000",
"20240102",
"20240102",
adjust="",
)
assert calls == [{
"symbol": "600000",
"period": "daily",
"start_date": "20240102",
"end_date": "20240102",
"adjust": "",
}]
assert result is not None
assert result.columns.tolist() == [
"symbol", "date", "open", "high", "low", "close", "volume", "amount",
]
assert result["symbol"].tolist() == ["sh600000"]
monkeypatch.setattr(downloader.ak, "stock_zh_a_hist", lambda **kwargs: pd.DataFrame())
assert downloader._download_akshare("sh600000", "20240102", "20240102") is None
monkeypatch.setattr(
downloader.ak,
"stock_zh_a_hist",
lambda **kwargs: (_ for _ in ()).throw(RuntimeError("provider down")),
)
assert downloader._download_akshare("sh600000", "20240102", "20240102") is None
def test_baostock_daily_downloader_maps_errors_and_logout_failures(monkeypatch):
query_calls: list[dict] = []
row = ["2024-01-02", "10", "11", "9", "10.5", "1000", "10500"]
monkeypatch.setattr(downloader.bs, "login", lambda: None)
monkeypatch.setattr(
downloader.bs,
"logout",
lambda: (_ for _ in ()).throw(RuntimeError("logout failed")),
)
def fake_query(**kwargs):
query_calls.append(kwargs)
return _FakeResult([row])
monkeypatch.setattr(downloader.bs, "query_history_k_data_plus", fake_query)
result = downloader._download_baostock(
"sz000001",
"2024-01-02",
"2024-01-02",
adjust="none",
)
assert query_calls[0]["code"] == "sz.000001"
assert query_calls[0]["adjustflag"] == "3"
assert result is not None
assert result["symbol"].tolist() == ["sz000001"]
assert pd.api.types.is_numeric_dtype(result["close"])
monkeypatch.setattr(downloader.bs, "logout", lambda: None)
monkeypatch.setattr(
downloader.bs,
"query_history_k_data_plus",
lambda **kwargs: _FakeResult([], error_code="1", error_msg="bad symbol"),
)
assert downloader._download_baostock("sz000001", "2024-01-02", "2024-01-02") is None
monkeypatch.setattr(
downloader.bs,
"query_history_k_data_plus",
lambda **kwargs: _FakeResult([]),
)
assert downloader._download_baostock("sz000001", "2024-01-02", "2024-01-02") is None
monkeypatch.setattr(
downloader.bs,
"query_history_k_data_plus",
lambda **kwargs: (_ for _ in ()).throw(RuntimeError("query failed")),
)
assert downloader._download_baostock("sz000001", "2024-01-02", "2024-01-02") is None
def test_download_daily_batch_maps_rich_schema_and_vwap(monkeypatch):
query_calls: list[dict] = []
login_count = 0
logout_count = 0
def fake_login():
nonlocal login_count
login_count += 1
def fake_logout():
nonlocal logout_count
logout_count += 1
def fake_query(**kwargs):
query_calls.append(kwargs)
rows = [
_daily_batch_row(volume="1000", amount="10500"),
_daily_batch_row(date="2024-01-03", volume="0", amount="0"),
]
return _FakeResult(rows)
monkeypatch.setattr(downloader.bs, "login", fake_login)
monkeypatch.setattr(downloader.bs, "logout", fake_logout)
monkeypatch.setattr(downloader.bs, "query_history_k_data_plus", fake_query)
[(symbol, frame)] = list(
download_daily_batch(
["sh600000"],
"2024-01-02",
"2024-01-03",
adjust="hfq",
)
)
assert symbol == "sh600000"
assert query_calls[0]["code"] == "sh.600000"
assert query_calls[0]["adjustflag"] == "1"
assert login_count == 1
assert logout_count == 1
assert frame is not None
assert frame.columns.tolist() == [
"symbol", "date", "open", "high", "low", "close", "preclose",
"volume", "amount", "vwap", "turn", "pctChg", "tradestatus", "isST",
"peTTM", "pbMRQ", "psTTM", "pcfNcfTTM",
]
assert np.isclose(frame["vwap"].iloc[0], 10.5)
assert pd.isna(frame["vwap"].iloc[1])
assert pd.api.types.is_datetime64_any_dtype(frame["date"])
assert pd.api.types.is_numeric_dtype(frame["tradestatus"])
def test_download_daily_batch_periodic_relogin_and_none_result(monkeypatch):
responses = [
_FakeResult([], error_code="1", error_msg="bad symbol"),
_FakeResult([_daily_batch_row(date="2024-01-03")]),
]
login_count = 0
logout_count = 0
def fake_login():
nonlocal login_count
login_count += 1
def fake_logout():
nonlocal logout_count
logout_count += 1
monkeypatch.setattr(downloader.bs, "login", fake_login)
monkeypatch.setattr(downloader.bs, "logout", fake_logout)
monkeypatch.setattr(
downloader.bs,
"query_history_k_data_plus",
lambda **kwargs: responses.pop(0),
)
results = list(
download_daily_batch(
["sh600000", "sz000001"],
"2024-01-02",
"2024-01-03",
relogin_every=1,
)
)
assert results[0] == ("sh600000", None)
assert results[1][0] == "sz000001"
assert results[1][1] is not None
assert login_count == 2
assert logout_count == 2
def test_download_daily_batch_empty_rows_yields_none(monkeypatch):
monkeypatch.setattr(downloader.bs, "login", lambda: None)
monkeypatch.setattr(downloader.bs, "logout", lambda: None)
monkeypatch.setattr(
downloader.bs,
"query_history_k_data_plus",
lambda **kwargs: _FakeResult([]),
)
assert list(download_daily_batch(["sh600000"], "2024-01-02", "2024-01-02")) == [
("sh600000", None)
]
def test_download_daily_batch_generic_exception_yields_none(monkeypatch):
monkeypatch.setattr(downloader.bs, "login", lambda: None)
monkeypatch.setattr(downloader.bs, "logout", lambda: None)
monkeypatch.setattr(
downloader.bs,
"query_history_k_data_plus",
lambda **kwargs: (_ for _ in ()).throw(RuntimeError("query failed")),
)
assert list(download_daily_batch(["sh600000"], "2024-01-02", "2024-01-02")) == [
("sh600000", None)
]
def test_download_daily_batch_relogs_and_retries_session_loss(monkeypatch):
responses = [
_FakeResult([], error_code="10002007", error_msg="用户未登录"),
_FakeResult([_daily_batch_row()]),
]
login_count = 0
logout_count = 0
def fake_login():
nonlocal login_count
login_count += 1
def fake_logout():
nonlocal logout_count
logout_count += 1
monkeypatch.setattr(downloader.bs, "login", fake_login)
monkeypatch.setattr(downloader.bs, "logout", fake_logout)
monkeypatch.setattr(
downloader.bs,
"query_history_k_data_plus",
lambda **kwargs: responses.pop(0),
)
[(symbol, frame)] = list(download_daily_batch(["sh600000"], "2024-01-02", "2024-01-02"))
assert symbol == "sh600000"
assert frame is not None
assert len(frame) == 1
assert login_count == 2
assert logout_count == 2
def test_download_daily_batch_second_session_loss_and_logout_failure(monkeypatch):
responses = [
_FakeResult([], error_code="10002007", error_msg="用户未登录"),
_FakeResult([], error_code="10002007", error_msg="用户未登录"),
]
logout_count = 0
def fake_logout():
nonlocal logout_count
logout_count += 1
raise RuntimeError("logout failed")
monkeypatch.setattr(downloader.bs, "login", lambda: None)
monkeypatch.setattr(downloader.bs, "logout", fake_logout)
monkeypatch.setattr(
downloader.bs,
"query_history_k_data_plus",
lambda **kwargs: responses.pop(0),
)
assert list(download_daily_batch(["sh600000"], "2024-01-02", "2024-01-02")) == [
("sh600000", None)
]
assert logout_count == 2
def test_download_daily_batch_uses_akshare_fallback_when_enabled(monkeypatch):
fallback = pd.DataFrame({
"symbol": ["sh600000"],
"date": ["2024-01-02"],
"open": [10.0],
"high": [11.0],
"low": [9.0],
"close": [10.5],
"volume": [1000.0],
"amount": [10500.0],
})
monkeypatch.setattr(downloader.bs, "login", lambda: None)
monkeypatch.setattr(downloader.bs, "logout", lambda: None)
monkeypatch.setattr(
downloader.bs,
"query_history_k_data_plus",
lambda **kwargs: _FakeResult([], error_code="1", error_msg="no data"),
)
monkeypatch.setattr(
downloader,
"_download_akshare",
lambda symbol, start, end, adjust: fallback.copy(),
)
[(symbol, frame)] = list(
download_daily_batch(
["sh600000"],
"2024-01-02",
"2024-01-02",
akshare_fallback=True,
)
)
assert symbol == "sh600000"
assert frame is not None
assert frame["date"].tolist() == [pd.Timestamp("2024-01-02")]
assert frame["close"].tolist() == [10.5]
def test_download_universe_writes_daily_partitions_from_mock_batch(tmp_path, monkeypatch):
batch_frame = pd.DataFrame({
"symbol": ["sh600000", "sh600000"],
"date": pd.to_datetime(["2024-01-02", "2024-02-01"]),
"open": [10.0, 11.0],
"high": [11.0, 12.0],
"low": [9.0, 10.0],
"close": [10.5, 11.5],
"preclose": [10.0, 10.5],
"volume": [1000.0, 1200.0],
"amount": [10500.0, 13800.0],
"vwap": [10.5, 11.5],
"turn": [1.0, 1.1],
"pctChg": [5.0, 9.5],
"tradestatus": [1, 1],
"isST": [0, 0],
"peTTM": [8.0, 8.1],
"pbMRQ": [1.1, 1.2],
"psTTM": [2.1, 2.2],
"pcfNcfTTM": [3.1, 3.2],
})
monkeypatch.setattr(
pipeline_downloader,
"_resolve_universe",
lambda universe, max_symbols=0: pd.DataFrame({
"symbol_id": ["sh600000", "sz000001"],
"symbol_name": ["PF Bank", "Ping An Bank"],
}),
)
def fake_batch(symbols, start, end, adjust="qfq"):
assert symbols == ["sh600000", "sz000001"]
assert adjust == "qfq"
yield "sh600000", batch_frame
yield "sz000001", None
monkeypatch.setattr(pipeline_downloader, "download_daily_batch", fake_batch)
stale_file = tmp_path / "toy" / "month=2024-01" / "stale.pq"
stale_file.parent.mkdir(parents=True)
batch_frame.iloc[[0]][DATA_COLUMNS[2:]].to_parquet(stale_file, index=False)
stats = download_universe(
universe="toy",
start_date="2024-01-02",
end_date="2024-02-01",
output_dir=str(tmp_path),
chunk_size=1,
)
dataset_path = tmp_path / "toy"
written = pd.read_parquet(dataset_path).sort_values(["date", "symbol_id"]).reset_index(drop=True)
assert stats == {
"dataset_path": str(dataset_path),
"n_symbols": 1,
"n_requested": 2,
"n_rows": 2,
"date_min": "2024-01-02",
"date_max": "2024-02-01",
}
assert (dataset_path / "month=2024-01").exists()
assert (dataset_path / "month=2024-02").exists()
assert not stale_file.exists()
assert written[DATA_COLUMNS].columns.tolist() == DATA_COLUMNS
assert written["symbol_name"].tolist() == ["PF Bank", "PF Bank"]
def test_download_universe_raises_when_all_daily_symbols_empty(tmp_path, monkeypatch):
monkeypatch.setattr(
pipeline_downloader,
"_resolve_universe",
lambda universe, max_symbols=0: pd.DataFrame({
"symbol_id": ["sh600000"],
"symbol_name": ["PF Bank"],
}),
)
monkeypatch.setattr(
pipeline_downloader,
"download_daily_batch",
lambda symbols, start, end, adjust="qfq": iter([("sh600000", None)]),
)
with pytest.raises(RuntimeError, match="No data downloaded"):
download_universe(
universe="toy",
start_date="2024-01-02",
end_date="2024-01-02",
output_dir=str(tmp_path),
)
def test_download_universe_progress_branch_at_100_symbols(tmp_path, monkeypatch):
symbols = [f"sh6{i:05d}" for i in range(100)]
batch_frame = pd.DataFrame({
"symbol": ["sh600000"],
"date": [pd.Timestamp("2024-01-02")],
"open": [10.0],
"high": [11.0],
"low": [9.0],
"close": [10.5],
"preclose": [10.0],
"volume": [1000.0],
"amount": [10500.0],
"vwap": [10.5],
"turn": [1.0],
"pctChg": [5.0],
"tradestatus": [1],
"isST": [0],
"peTTM": [8.0],
"pbMRQ": [1.1],
"psTTM": [2.1],
"pcfNcfTTM": [3.1],
})
monkeypatch.setattr(
pipeline_downloader,
"_resolve_universe",
lambda universe, max_symbols=0: pd.DataFrame({
"symbol_id": symbols,
"symbol_name": symbols,
}),
)
def fake_batch(requested_symbols, start, end, adjust="qfq"):
assert requested_symbols == symbols
for symbol in requested_symbols:
yield symbol, batch_frame.copy()
monkeypatch.setattr(pipeline_downloader, "download_daily_batch", fake_batch)
stats = download_universe(
universe="toy100",
start_date="2024-01-02",
end_date="2024-01-02",
output_dir=str(tmp_path),
chunk_size=200,
)
assert stats["n_symbols"] == 100
assert stats["n_rows"] == 100