a68cf4905e
把聚宽"全天候轮动"(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
264 lines
9.2 KiB
Python
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)
|