"""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)