b270faf4b9
- BaostockProvider: 读 VPS daily_baostock_full(本地,不调online,守 provider-local-data-only 铁律) - LocalParquetProvider: 读 parquet 兜底,回测117交易日0.4s/月出JSON - all_weather 策略 + runner_backtest 适配 - 数据源融合使用层(单 Provider 内部路由,见 data-fusion spec §6)
435 lines
15 KiB
Python
435 lines
15 KiB
Python
"""pytest 配置 + mock xtquant / baostock fixtures。
|
|
|
|
约束:
|
|
- Mac 没 xtquant/miniQMT,所有 ``from xtquant import xtdata`` 必须 mock
|
|
- baostock 已装在 venv310,但仍提供 mock fixture(单测不依赖网络)
|
|
- bullet-trade 0.9.2 的 ``import bullet_trade`` 会触发 default provider=jqdata → import jqdatasdk
|
|
→ 在 ``import bullet_trade`` 前设 ``DEFAULT_DATA_PROVIDER``(本文件最顶部)
|
|
- bullet-trade 可能没装完,所有 bullet-trade import 容错 skip
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
# 必须早于任何 bullet_trade import / sanguo_portfolio(它可能 lazy import bullet_trade)
|
|
# sanguo_portfolio.providers.baostock_provider 顶部会 setdefault sanguo_baostock;
|
|
# 这里若未指定则用 miniqmt(向后兼容 SanguoMiniQmtProvider 测试)
|
|
os.environ.setdefault("DEFAULT_DATA_PROVIDER", "miniqmt")
|
|
|
|
import sys
|
|
import types
|
|
from datetime import datetime
|
|
from typing import Any, Dict, List
|
|
from unittest.mock import MagicMock
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
|
|
# ------------------------ xtquant mock ------------------------
|
|
@pytest.fixture
|
|
def mock_xtquant():
|
|
"""构造 xtquant.xtdata MagicMock,返回 PershareIndex/Capital/Balance/Income/CashFlow。
|
|
|
|
yield 一个 dict,可在外层覆盖任意返回值进行定制。
|
|
"""
|
|
xtdata = MagicMock(name="xtdata")
|
|
|
|
# 默认财务数据:dict[stock] -> dict[table] -> DataFrame
|
|
fin_data = _build_default_financial_data()
|
|
xtdata.get_financial_data.return_value = fin_data
|
|
xtdata.download_financial_data.return_value = True
|
|
|
|
# 默认 close:dict[stock] -> DataFrame(index=[date], close=[10.0])
|
|
xtdata.get_market_data_ex.return_value = _build_default_close()
|
|
|
|
# get_instrument_detail / get_full_tick 等基本返回
|
|
xtdata.get_instrument_detail.return_value = {
|
|
"InstrumentName": "贵州茅台",
|
|
"InstrumentID": "600519",
|
|
"OpenDate": "2001-08-27",
|
|
"ExpireDate": "",
|
|
}
|
|
xtdata.get_full_tick.return_value = {}
|
|
|
|
module = types.ModuleType("xtquant")
|
|
submodule = types.ModuleType("xtquant.xtdata")
|
|
# 把 MagicMock 当作 xtquant.xtdata 模块
|
|
sys.modules["xtquant"] = module
|
|
sys.modules["xtquant.xtdata"] = xtdata
|
|
try:
|
|
yield {
|
|
"xtdata": xtdata,
|
|
"fin_data": fin_data,
|
|
}
|
|
finally:
|
|
sys.modules.pop("xtquant", None)
|
|
sys.modules.pop("xtquant.xtdata", None)
|
|
|
|
|
|
def _build_default_financial_data() -> Dict[str, Dict[str, pd.DataFrame]]:
|
|
"""构造 2 只股票的完整财务数据(茅台 + 平安)。"""
|
|
# 通用报告期 index
|
|
report_dates = pd.to_datetime(["2024-03-31", "2024-06-30", "2024-09-30"])
|
|
|
|
# 茅台 600519.SH
|
|
maotai_psh = pd.DataFrame({
|
|
"roe": [30.0, 31.0, 32.0],
|
|
"roa": [20.0, 21.0, 22.0],
|
|
"eps": [39.0, 40.0, 41.0],
|
|
"gross_profit_margin": [91.0, 91.5, 92.0],
|
|
"net_profit_margin": [50.0, 51.0, 52.0],
|
|
"inc_revenue_year_on_year": [16.0, 17.0, 15.0],
|
|
"inc_operation_profit_year_on_year": [15.0, 16.0, 14.0],
|
|
"inc_total_revenue_year_on_year": [16.0, 17.0, 15.0],
|
|
"actual_tax_rate": [25.0, 25.0, 25.0],
|
|
}, index=report_dates)
|
|
|
|
maotai_balance = pd.DataFrame({
|
|
"total_liability": [5e10, 5.2e10, 5.4e10],
|
|
"total_sheet_owner_equities": [2.0e11, 2.1e11, 2.2e11],
|
|
"tot_shrhldr_eqy_excl_min_int": [2.0e11, 2.1e11, 2.2e11],
|
|
"retained_profit": [1.5e11, 1.6e11, 1.7e11],
|
|
"cash_equivalents": [1.5e11, 1.6e11, 1.7e11],
|
|
"short_loan": [0.0, 0.0, 0.0],
|
|
"long_loan": [0.0, 0.0, 0.0],
|
|
"bonds_payable": [0.0, 0.0, 0.0],
|
|
}, index=report_dates)
|
|
|
|
maotai_income = pd.DataFrame({
|
|
"net_profit_excl_min_int": [8.6e9, 9.0e9, 9.5e9],
|
|
"operating_revenue": [3.5e10, 3.6e10, 3.7e10],
|
|
"oper_profit": [1.1e10, 1.15e10, 1.2e10],
|
|
"inc_tax": [2.5e9, 2.6e9, 2.7e9],
|
|
"profit_before_tax": [1.0e10, 1.05e10, 1.1e10],
|
|
}, index=report_dates)
|
|
|
|
maotai_cashflow = pd.DataFrame({
|
|
"n_cashflow_act": [5.0e9, 5.2e9, 5.5e9],
|
|
}, index=report_dates)
|
|
|
|
maotai_capital = pd.DataFrame({
|
|
# miniQMT Capital 单位 = 股(实证 2026-07-18),茅台 12.56 亿股 = 1.256e9
|
|
"total_capital": [1_256_000_000.0, 1_256_000_000.0, 1_256_000_000.0],
|
|
"circulating_capital": [1_256_000_000.0, 1_256_000_000.0, 1_256_000_000.0],
|
|
}, index=report_dates)
|
|
|
|
# 平安 601318.SH
|
|
pingan_psh = pd.DataFrame({
|
|
"roe": [10.0, 11.0, 12.0],
|
|
"roa": [1.2, 1.3, 1.4],
|
|
"eps": [1.5, 1.6, 1.7],
|
|
"gross_profit_margin": [40.0, 41.0, 42.0],
|
|
"net_profit_margin": [10.0, 11.0, 12.0],
|
|
"inc_revenue_year_on_year": [5.0, 6.0, 7.0],
|
|
"inc_operation_profit_year_on_year": [4.0, 5.0, 6.0],
|
|
"inc_total_revenue_year_on_year": [5.0, 6.0, 7.0],
|
|
"actual_tax_rate": [15.0, 15.0, 15.0],
|
|
}, index=report_dates)
|
|
|
|
pingan_balance = pd.DataFrame({
|
|
"total_liability": [9.0e12, 9.1e12, 9.2e12],
|
|
"total_sheet_owner_equities": [9.0e11, 9.2e11, 9.4e11],
|
|
"tot_shrhldr_eqy_excl_min_int": [9.0e11, 9.2e11, 9.4e11],
|
|
"retained_profit": [5.0e11, 5.1e11, 5.2e11],
|
|
"cash_equivalents": [8.0e11, 8.2e11, 8.4e11],
|
|
"short_loan": [0.0, 0.0, 0.0],
|
|
"long_loan": [5.0e10, 5.1e10, 5.2e10],
|
|
"bonds_payable": [3.0e10, 3.1e10, 3.2e10],
|
|
}, index=report_dates)
|
|
|
|
pingan_income = pd.DataFrame({
|
|
"net_profit_excl_min_int": [4.0e10, 4.2e10, 4.4e10],
|
|
"operating_revenue": [3.0e11, 3.1e11, 3.2e11],
|
|
"oper_profit": [5.0e10, 5.2e10, 5.4e10],
|
|
"inc_tax": [8.0e9, 8.2e9, 8.4e9],
|
|
"profit_before_tax": [5.5e10, 5.7e10, 5.9e10],
|
|
}, index=report_dates)
|
|
|
|
pingan_cashflow = pd.DataFrame({
|
|
"n_cashflow_act": [2.0e10, 2.1e10, 2.2e10],
|
|
}, index=report_dates)
|
|
|
|
pingan_capital = pd.DataFrame({
|
|
# 平安总股本 182.8 亿股 = 1.828e10 股
|
|
"total_capital": [18_280_000_000.0, 18_280_000_000.0, 18_280_000_000.0],
|
|
"circulating_capital": [18_280_000_000.0, 18_280_000_000.0, 18_280_000_000.0],
|
|
}, index=report_dates)
|
|
|
|
return {
|
|
"600519.SH": {
|
|
"PershareIndex": maotai_psh,
|
|
"Balance": maotai_balance,
|
|
"Income": maotai_income,
|
|
"CashFlow": maotai_cashflow,
|
|
"Capital": maotai_capital,
|
|
},
|
|
"601318.SH": {
|
|
"PershareIndex": pingan_psh,
|
|
"Balance": pingan_balance,
|
|
"Income": pingan_income,
|
|
"CashFlow": pingan_cashflow,
|
|
"Capital": pingan_capital,
|
|
},
|
|
}
|
|
|
|
|
|
def _build_default_close() -> Dict[str, pd.DataFrame]:
|
|
"""构造 close DataFrame(MiniQMTProvider 风格)。"""
|
|
dates = pd.to_datetime(["2024-09-27", "2024-09-30"])
|
|
return {
|
|
"600519.SH": pd.DataFrame({"close": [1600.0, 1620.0]}, index=dates),
|
|
"601318.SH": pd.DataFrame({"close": [50.0, 51.0]}, index=dates),
|
|
}
|
|
|
|
|
|
# ------------------------ mock context ------------------------
|
|
class FakePosition:
|
|
def __init__(self, code: str, avg_cost: float, price: float, total: int = 100):
|
|
self.security = code
|
|
self.avg_cost = avg_cost
|
|
self.price = price
|
|
self.last_sale_price = price
|
|
self.total_amount = total
|
|
|
|
|
|
class FakePortfolio:
|
|
def __init__(self, positions: Dict[str, FakePosition] | None = None, cash: float = 1e6):
|
|
self.positions = positions or {}
|
|
self.available_cash = cash
|
|
self.cash = cash
|
|
|
|
|
|
class FakeContext:
|
|
def __init__(
|
|
self,
|
|
current_dt: datetime | None = None,
|
|
previous_date: str | None = "2024-09-30",
|
|
positions: Dict[str, FakePosition] | None = None,
|
|
cash: float = 1e6,
|
|
):
|
|
self.current_dt = current_dt or datetime(2024, 10, 8, 14, 0)
|
|
self.previous_date = previous_date
|
|
self.portfolio = FakePortfolio(positions, cash)
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_context():
|
|
"""空白 FakeContext,无持仓,1e6 现金。"""
|
|
return FakeContext()
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_context_with_position():
|
|
"""FakeContext 含 1 只持仓(用于 stop_loss / monthly_adjustment 测试)。"""
|
|
pos = {"600519.XSHG": FakePosition("600519.XSHG", avg_cost=1600.0, price=1500.0)}
|
|
return FakeContext(positions=pos)
|
|
|
|
|
|
# ------------------------ mock provider ------------------------
|
|
@pytest.fixture
|
|
def mock_provider():
|
|
"""数据 provider 的 MagicMock,默认返回符合策略预期的 DataFrame。"""
|
|
p = MagicMock(name="mock_provider")
|
|
# get_security_info:正常返回
|
|
p.get_security_info.side_effect = lambda code, date=None: {
|
|
"display_name": "贵州茅台" if "600519" in code else "中国平安",
|
|
"name": code.split(".")[0],
|
|
"start_date": datetime(2001, 8, 27),
|
|
}
|
|
# get_live_current / get_current_tick:返回 paused=False,high/low_limit
|
|
p.get_live_current.side_effect = lambda code: {
|
|
"paused": False,
|
|
"last_price": 1600.0,
|
|
"high_limit": 1760.0,
|
|
"low_limit": 1440.0,
|
|
}
|
|
return p
|
|
|
|
|
|
# ------------------------ bullet-trade skip helper ------------------------
|
|
def pytest_collection_modifyitems(config, items):
|
|
"""自动给需要 bullet-trade 的测试加 skip 标记(若未装)。"""
|
|
try:
|
|
import bullet_trade # noqa: F401
|
|
bt_available = True
|
|
except Exception:
|
|
bt_available = False
|
|
|
|
if bt_available:
|
|
return
|
|
|
|
skip_bt = pytest.mark.skip(reason="bullet-trade 未装,跳过 provider 实例化相关测试")
|
|
for item in items:
|
|
if "requires_bullet_trade" in item.keywords:
|
|
item.add_marker(skip_bt)
|
|
|
|
|
|
# ------------------------ baostock mock ------------------------
|
|
class _FakeResultData:
|
|
"""模拟 baostock ResultData(支持 next()/get_row_data()/fields/get_data())。
|
|
|
|
用 ``pd.DataFrame`` 构造,迭代器风格访问兼容 baostock 官方文档示例。
|
|
"""
|
|
|
|
def __init__(self, df: pd.DataFrame, error_code: str = "0", error_msg: str = "success"):
|
|
self._df = df.reset_index(drop=True) if isinstance(df, pd.DataFrame) else pd.DataFrame()
|
|
self._idx = -1
|
|
self.error_code = error_code
|
|
self.error_msg = error_msg
|
|
self.fields: List[str] = list(self._df.columns)
|
|
|
|
def next(self) -> bool:
|
|
self._idx += 1
|
|
return self._idx < len(self._df)
|
|
|
|
def get_row_data(self) -> List[Any]:
|
|
if 0 <= self._idx < len(self._df):
|
|
return [self._df.iloc[self._idx][c] for c in self._df.columns]
|
|
return []
|
|
|
|
def get_data(self) -> pd.DataFrame:
|
|
return self._df.copy()
|
|
|
|
|
|
def _build_default_kline_df(code: str = "sh.600519") -> pd.DataFrame:
|
|
"""构造 2 根日线(baostock 字符串风格)。"""
|
|
return pd.DataFrame({
|
|
"date": ["2024-09-27", "2024-09-30"],
|
|
"code": [code, code],
|
|
"open": ["1580.0", "1610.0"],
|
|
"high": ["1610.0", "1630.0"],
|
|
"low": ["1575.0", "1605.0"],
|
|
"close": ["1600.0", "1620.0"],
|
|
"preclose": ["1570.0", "1600.0"],
|
|
"volume": ["1000000", "1200000"],
|
|
"amount": ["1.6e9", "1.94e9"],
|
|
"adjustflag": ["2", "2"],
|
|
"turn": ["0.08", "0.10"],
|
|
"tradestatus": ["1", "1"],
|
|
"pctChg": ["1.91", "1.25"],
|
|
"peTTM": ["25.5", "25.8"],
|
|
"pbMRQ": ["7.5", "7.6"],
|
|
"psTTM": ["15.2", "15.4"],
|
|
"pcfNcfTTM": ["20.1", "20.3"],
|
|
"isST": ["0", "0"],
|
|
})
|
|
|
|
|
|
def _build_default_profit_df(net_profit_ytd: float = 5.0e10,
|
|
revenue_ytd: float = 1.0e11,
|
|
roe_avg: float = 30.0,
|
|
eps_ttm: float = 40.0,
|
|
total_share: float = 1.256e9) -> pd.DataFrame:
|
|
"""构造 query_profit_data 单季报返回(单行)。
|
|
|
|
Args 允许测试覆盖,默认茅台 2024 Q3(累计口径:前三季净利 500 亿,营收 1000 亿)。
|
|
"""
|
|
return pd.DataFrame({
|
|
"code": ["sh.600519"],
|
|
"pubDate": ["2024-10-15"],
|
|
"statDate": ["2024-09-30"],
|
|
"roeAvg": [str(roe_avg)],
|
|
"npMargin": ["50.0"],
|
|
"gpMargin": ["91.0"],
|
|
"netProfit": [str(net_profit_ytd)],
|
|
"epsTTM": [str(eps_ttm)],
|
|
"MBRevenue": [str(revenue_ytd)],
|
|
"totalShare": [str(total_share)],
|
|
"liqaShare": [str(total_share)],
|
|
})
|
|
|
|
|
|
def _build_default_hs300_df() -> pd.DataFrame:
|
|
"""构造 query_hs300_stocks 返回(3 只成分股)。"""
|
|
return pd.DataFrame({
|
|
"updateDate": ["2024-09-30"] * 3,
|
|
"code": ["sh.600519", "sh.601318", "sz.000001"],
|
|
"code_name": ["贵州茅台", "中国平安", "平安银行"],
|
|
})
|
|
|
|
|
|
def _build_default_stock_basic_df() -> pd.DataFrame:
|
|
"""构造 query_stock_basic 返回(茅台)。"""
|
|
return pd.DataFrame({
|
|
"code": ["sh.600519"],
|
|
"code_name": ["贵州茅台"],
|
|
"ipoDate": ["2001-08-27"],
|
|
"outDate": [""],
|
|
"type": ["1"],
|
|
"status": ["1"],
|
|
})
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_baostock():
|
|
"""构造 baostock MagicMock,覆盖 login/query_history_k_data_plus/query_hs300_stocks/
|
|
query_profit_data/query_stock_basic 等。
|
|
|
|
yield dict,可在外层覆盖任意 query_xxx 的返回值定制。
|
|
"""
|
|
bs = MagicMock(name="baostock")
|
|
# login/logout
|
|
bs.login.return_value = MagicMock(error_code="0", error_msg="success")
|
|
bs.logout.return_value = MagicMock(error_code="0", error_msg="success")
|
|
|
|
# K 线
|
|
bs.query_history_k_data_plus.return_value = _FakeResultData(_build_default_kline_df())
|
|
|
|
# 成分股
|
|
bs.query_hs300_stocks.return_value = _FakeResultData(_build_default_hs300_df())
|
|
bs.query_zz500_stocks.return_value = _FakeResultData(_build_default_hs300_df())
|
|
bs.query_sz50_stocks.return_value = _FakeResultData(_build_default_hs300_df())
|
|
|
|
# 季报(默认茅台 2024 Q3 累计净利 500 亿,营收 1000 亿)
|
|
bs.query_profit_data.return_value = _FakeResultData(_build_default_profit_df())
|
|
|
|
# 证券基本资料
|
|
bs.query_stock_basic.return_value = _FakeResultData(_build_default_stock_basic_df())
|
|
|
|
# 交易日
|
|
bs.query_trade_dates.return_value = _FakeResultData(pd.DataFrame({
|
|
"calendar_date": ["2024-09-27", "2024-09-30"],
|
|
"is_trading_day": ["1", "1"],
|
|
}))
|
|
|
|
# 全部证券
|
|
bs.query_all_stock.return_value = _FakeResultData(pd.DataFrame({
|
|
"code": ["sh.600519", "sh.601318"],
|
|
"tradeStatus": ["1", "1"],
|
|
"code_name": ["贵州茅台", "中国平安"],
|
|
}))
|
|
|
|
# 复权因子
|
|
bs.query_adjust_factor.return_value = _FakeResultData(pd.DataFrame({
|
|
"code": ["sh.600519"],
|
|
"dividOperateDate": ["2024-06-19"],
|
|
"foreAdjustFactor": ["0.99"],
|
|
"backAdjustFactor": ["1.01"],
|
|
"adjustFactor": ["1.01"],
|
|
}))
|
|
|
|
module = types.ModuleType("baostock")
|
|
# 把 MagicMock 当作 baostock 模块(sys.modules)
|
|
sys.modules["baostock"] = bs
|
|
|
|
# 同时直接 patch BaostockProvider._import_baostock(更稳定,
|
|
# 不受 pytest-cov 改变 import 行为影响)
|
|
from sanguo_portfolio.providers.baostock_provider import BaostockProvider
|
|
original_import = BaostockProvider._import_baostock
|
|
BaostockProvider._import_baostock = staticmethod(lambda: bs) # type: ignore[assignment]
|
|
|
|
try:
|
|
yield {
|
|
"bs": bs,
|
|
"kline_df": _build_default_kline_df,
|
|
"profit_df": _build_default_profit_df,
|
|
"hs300_df": _build_default_hs300_df,
|
|
"stock_basic_df": _build_default_stock_basic_df,
|
|
"FakeResultData": _FakeResultData,
|
|
}
|
|
finally:
|
|
BaostockProvider._import_baostock = original_import # type: ignore[assignment]
|
|
sys.modules.pop("baostock", None)
|