Files
sanguo_vnpy_v2/tests/portfolio/conftest.py
T
claude_dev b270faf4b9 feat(portfolio): 本地数据 provider 层(baostock/local_parquet)
- 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)
2026-07-22 10:35:23 +08:00

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)