Files
sanguo_vnpy_v2/tests/portfolio/conftest.py
T
claude_dev a68cf4905e feat(portfolio): sanguo_portfolio 组合策略框架(BulletTrade+miniQMT,不用jqdatasdk)
把聚宽"全天候轮动"(post48819)搬到 BulletTrade。融合=pip+扩展点注入
(SanguoMiniQmtProvider 继承 MiniQMTProvider 只 override get_fundamentals,
set_data_provider 公开 API 注入, BulletTrade 源码 0 改动)。

- providers: SanguoMiniQmtProvider 补 get_fundamentals(PershareIndex+自算PE/PS/PB/PCF/市值/ROIC)
- strategies/all_weather: 4选股函数+大小盘轮动+ETF兜底+涨停止损(聚宽风格翻译)
- factors(估值/ROIC自算) + filters(ST/涨跌停/次新/停牌)
- 88/88 测试 Mac+VPS 双过; VPS 回测 pipeline 跑通(修9bug:Capital单位/日期格式/百分数口径/11字段alias)
- 实盘 runner_live+runbook 就绪等交易日; DEFAULT_DATA_PROVIDER=miniqmt env 不装 jqdatasdk
- 文档: sanguo_portfolio_plan / portfolio_backtest_result / portfolio_live_runbook
2026-07-18 19:08:18 +08:00

264 lines
9.2 KiB
Python

"""pytest 配置 + mock xtquant fixtures。
约束:
- Mac 没 xtquant/miniQMT,所有 ``from xtquant import xtdata`` 必须 mock
- bullet-trade 0.9.2 的 ``import bullet_trade`` 会触发 default provider=jqdata → import jqdatasdk
→ 在 ``import bullet_trade`` 前设 ``DEFAULT_DATA_PROVIDER=miniqmt``(本文件最顶部)
- bullet-trade 可能没装完,所有 bullet-trade import 容错 skip
"""
from __future__ import annotations
import os
# 必须早于任何 bullet_trade import / sanguo_portfolio(它可能 lazy import bullet_trade)
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)