Files
sanguo_vnpy_v2/docs/sanguo_portfolio_plan.md
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

16 KiB
Raw Blame History

sanguo_portfolio 实施计划

把聚宽"全天候轮动"策略(post48819)搬到 BulletTrade 框架,数据源 miniQMT(不用 jqdatasdk),回测验证 + 实盘就绪。

背景已确认(实证)

  • BulletTrade 0.9.2MIT),pip install bullet-trade[all],聚宽 API 100% 兼容
  • 融合机制已验证Mac 最小依赖实证 8 项全过):set_data_provider(provider实例) 公开 APIdata/api.py:290),继承 MiniQMTProvider 只 override get_fundamentals,源码 0 改动
  • get_fundamentals 是 base.py:159 可选方法(非 abstract,默认抛 NotImplementedError),MiniQMTProvider 未实现 = 唯一缺口
  • xtquant/jqdatasdk 全 lazy import,顶部不强拉
  • miniQMT 基本面(已 VPS 实证):xtdata.get_financial_dataPershareIndex(现成 ROE/ROA/毛利率/净利率/EPS/营收同比/资产负债率/存货周转率) + Capital(total_capital/circulating_capital/freeFloatCapital) + Balance/Income/CashFlow
  • 行情:xtdata.get_market_data_ex(close), get_full_tick(涨跌停 limit_up/limit_down), get_stock_list_in_sector(成分股), get_instrument_detail(上市日/名称)

环境

  • 开发:Macvenv310(py3.10.14)bullet-trade[all] 装中
  • 回测/实盘:VPS Windows(49.232.102.198)py3.10 + miniQMT(行情+基本面+下单都在那)
  • xtquant 在 Mac 不可用 → unit test 必须 mock xtquant;回测 rsync 到 VPS 跑

模块结构(新建 sanguo_portfolio/

sanguo_portfolio/
├── __init__.py
├── providers/
│   ├── __init__.py
│   └── sanguo_fundamentals.py   # SanguoMiniQmtProvider(MiniQMTProvider)
├── factors/
│   ├── __init__.py
│   ├── valuation.py              # PE/PS/PB/PCF/市值 自算
│   └── roic.py                   # ROIC 自算
├── filters.py                    # ST/停牌/科创北交/次新/涨跌停 过滤
├── strategies/
│   ├── __init__.py
│   └── all_weather.py            # 全天候轮动(聚宽 post48819 翻译)
├── runner_backtest.py            # 回测入口
├── runner_live.py                # 实盘入口(等交易日)
└── config.yaml
tests/portfolio/
├── __init__.py
├── conftest.py                   # mock xtquant fixture
├── test_factors.py               # valuation/roic 纯函数测试
├── test_filters.py               # 过滤逻辑测试
├── test_provider.py              # provider 注入+get_fundamentals 测试(mock)
└── test_all_weather.py           # 策略选股逻辑测试(mock 数据)

文件 spec

factors/valuation.py(纯函数,易测)

def calc_market_cap(close, total_capital): return close * total_capital  # 元
def calc_circulating_market_cap(close, circulating_capital): return close * circulating_capital
def calc_pe(close, net_profit_excl_min_int, total_capital):
    # net_profit_excl_min_int = 归母净利润(单期, 非TTM); TTM 见下
    return (close * total_capital) / max(net_profit_excl_min_int*4, 1e-9)  # 简化:单期×4估TTM(标注口径)
def calc_pb(close, tot_shrhldr_eqy_excl_min_int, total_capital):
    return (close * total_capital) / max(tot_shrhldr_eqy_excl_min_int, 1e-9)
def calc_ps(close, revenue, total_capital): ...
def calc_pcf(close, net_oper_cash_flow, total_capital): ...

口径说明:PE/PB/PS/PCF 用最近报告期单期值×4近似 TTM(标注"近似口径,对账聚宽时校准")。精确 TTM 滚 4 季度留 v2。

factors/roic.py

def calc_roic(oper_profit, actual_tax_rate, tot_shrhldr_eqy, interest_bearing_debt, cash_equivalents):
    nopat = oper_profit * (1 - (actual_tax_rate/100 if actual_tax_rate>1 else actual_tax_rate))
    invested_capital = tot_shrhldr_eqy + interest_bearing_debt - cash_equivalents
    return nopat / max(invested_capital, 1e-9)

字段来自 Income.oper_profit / PershareIndex.actual_tax_rate / Balance.tot_shrhldr_eqy_excl_min_int / Balance(短期借款+长期借款+应付债券) / Balance.cash_equivalents。 注意:actual_tax_rate 在 PershareIndex 是百分比(如 20=20%)还是小数(0.2),实证时确认(茅台 actual_tax_rate 字段之前 NaN,用 Income.inc_tax/利润总额 兜底算)。

providers/sanguo_fundamentals.py(核心)

from bullet_trade.data.providers.miniqmt import MiniQMTProvider
class SanguoMiniQmtProvider(MiniQMTProvider):
    """继承 MiniQMTProvider(行情/成分/涨跌停全继承), 补 get_fundamentals 用 PershareIndex+自算估值/ROIC。"""
    name = "sanguo_miniqmt"

    def get_fundamentals(self, query_object, date=None, statDate=None):
        """聚宽风格 query 支持 + 直接 DataFrame 两种模式。
        聚宽 query(valuation, indicator).filter(...).order_by(...) 是 ORM,
        BulletTrade 透传 query_object。为兼容聚宽原策略, 解析 query 的 filter 条件
        映射到 DataFrame 列筛选(支持 ==/>/</between/in_/order_by/limit)。
        简化实现: 若 query_object 是 dict({'stocks':[...], 'date':...}) 直接返 DataFrame。
        """
        # 1. 取股票池(从 query 或参数)
        # 2. xtdata.download_financial_data + get_financial_data 取 PershareIndex/Balance/Income/CashFlow/Capital
        # 3. xtdata.get_market_data_ex 取 close
        # 4. 合并成 DataFrame: columns 含 code/market_cap/circulating_market_cap/pe_ratio/pb_ratio/ps_ratio/pcf_ratio
        #    + indicator(roe/roa/eps/gross_profit_margin/net_profit_margin/inc_revenue_year_on_year/inc_operation_profit_year_on_year/net_profit_margin)
        #    + balance(total_liability/total_sheet_owner_equities/retained_profit)
        # 5. 解析聚宽 query filter 应用筛选+order_by+limit
        # 6. 返回 DataFrame(聚宽 get_fundamentals 语义)
        ...

    # 供策略直接调的便捷方法(非聚宽标准)
    def get_fundamentals_df(self, stocks, date):
        """返合并 DataFrame, 策略可 pandas 风格筛选(避开 ORM 解析)。"""

关键:聚宽 query ORM 解析复杂,优先支持 get_fundamentals_df 让策略用 pandas 风格;get_fundamentals(query_object) 做基础解析(支持 in_/order_by/limit 最常用),复杂 filter 标注 NotImplementedError。

filters.py

def filter_st_stock(stocks, provider, date=None): ...      # name 含 ST/*/退
def filter_paused_stock(stocks, provider): ...              # paused
def filter_kcbj_stock(stocks): ...                          # 代码 4/8/68/3 开头
def filter_new_stock(stocks, provider, date, days=375): ... # 上市<days天
def filter_limitup_stock(stocks, provider, positions): ...  # close >= high_limit 排除(持仓除外)
def filter_limitdown_stock(stocks, provider, positions): ...# close <= low_limit 排除

用 provider.get_security_info / get_current_dataMiniQMTProvider 已实现)。

strategies/all_weather.py(聚宽 post48819 翻译)

完整聚宽源码见下方附录。翻译要点:

  • from jqdata import * → BulletTrade 兼容层(保留)
  • get_fundamentals(query(...)) → 改用 provider.get_fundamentals_df(stocks, date) + pandas 筛选(改写 4 个选股函数 SMALL/BIG/ROIC_BIG/BM
  • get_factor_values(stock,'roic_ttm')factors.roic.calc_roic(...)
  • get_index_stocks('000300.XSHG') → provider.get_index_stocks(继承)
  • get_price(fields=['close','high_limit','low_limit']) → provider.get_price(继承)
  • order_target_value → BulletTrade 原生(继承,A股手数自动)
  • run_daily/run_monthly → BulletTrade scheduler(继承)
  • filter_st/kcbj/new/paused/limitup/limitdown → 用 filters.py
  • 海外 ETF(518880 等) → 同代码,BulletTrade 能下单 ETF

runner_backtest.py

# 配 BulletTrade BacktestEngine
# set_data_provider(SanguoMiniQmtProvider({"mode":"backtest",...}))
# 加载 all_weather 策略, 设回测区间/benchmark/初始资金
# 跑回测, 输出收益曲线/选股名单/指标到 docs/portfolio_backtest_result.md

注意:回测要连 miniQMT(Mac 没有) → 回测脚本在 VPS 跑。

runner_live.py(实盘就绪)

# 配 BulletTrade LiveEngine + QmtBroker
# set_data_provider(SanguoMiniQmtProvider({"mode":"live",...}))
# 加载 all_weather, 启动
# 小仓位, 等交易日

测试要求(Mac venv310mock xtquant

  • conftest.py 提供 mock_xtquant fixturesys.modules['xtquant.xtdata'] = MagicMock,返回构造的 PershareIndex/Capital DataFrame
  • test_factors.pyvaluation/roic 纯函数,给定输入断言输出(AAA 模式)
  • test_filters.py:各 filter 给定 stocks+mock provider 断言过滤结果
  • test_provider.pySanguoMiniQmtProvider 实例化(mock xtquant)、get_fundamentals_df 返回 DataFrame 含正确列、set_data_provider 注入生效
  • test_all_weather.pymock 数据下,monthly_adjustment 选股逻辑跑通,返回合理 target_list
  • 覆盖率目标 80%factors/filters 必须,provider/策略 mock 覆盖核心路径)

不要做

  • 不连真 miniQMTMac 没有),全 mock
  • 不解析聚宽 query 的全部 ORM(只支持最常用 in_/order_by/limit/filter 简单比较)
  • 不做精确 TTM(单期×4 近似,标注)
  • 不 pip install 到系统 python,只用 venv310

附录:聚宽全天候轮动策略源码(post48819,已提取)

(见 memory bullettrade-portfolio-framework.md 概述;完整源码 agent 可从 /Users/chufeng/.claude/projects/.../fa466663-*.jsonl 第1330行附近提取, 或本文件下方需 Execute agent 自行从 transcript 提取完整源码再翻译)

执行顺序

  1. factors(factors/valuation.py, factors/roic.py) + tests — 纯函数先做易测
  2. filters.py + tests
  3. providers/sanguo_fundamentals.py + tests(mock)
  4. strategies/all_weather.py + tests(mock)
  5. runner_backtest.py / runner_live.py
  6. venv310 跑 pytest tests/portfolio 全绿
  7. 报告:文件清单 + 测试结果 + 待 VPS 回测/实盘事项

执行结果

文件清单

sanguo_portfolio/
├── __init__.py                       # ENV GUARD: setdefault DEFAULT_DATA_PROVIDER=miniqmt
├── factors/
│   ├── __init__.py
│   ├── valuation.py                  # PE/PB/PS/PCF/市值 自算,单期×4 近似 TTM
│   └── roic.py                       # ROIC + actual_tax_rate 归一 + Income 兜底
├── filters.py                        # ST/停牌/科创北交/次新/涨跌停(纯函数,接 provider)
├── providers/
│   ├── __init__.py
│   └── sanguo_fundamentals.py        # SanguoMiniQmtProvider(MiniQMTProvider) 补 get_fundamentals
├── strategies/
│   ├── __init__.py
│   └── all_weather.py                # 全天候轮动(聚宽 post48819 翻译)
├── runner_backtest.py                # BacktestEngine 入口, ENV GUARD + set_data_provider
└── runner_live.py                    # LiveEngine + QmtBroker 入口, ENV GUARD
tests/portfolio/
├── __init__.py                       # ENV GUARD
├── conftest.py                       # mock_xtquant fixture + FakeContext/Position + skip 标记
├── test_factors.py                   # valuation + roic 纯函数 AAA
├── test_filters.py                   # 6 个 filter 全覆盖
├── test_provider.py                  # SanguoMiniQmtProvider 实例化/get_fundamentals_df/query dict 模式
└── test_all_weather.py               # initialize/prepare_stock_list/stop_loss/monthly_adjustment + SMALL/BIG/ROIC_BIG/BM

pytest.ini 注册 requires_bullet_trade mark;无 bullet-trade 时自动 skip provider 测试。

pytest 结果(Mac venv310 + bullet-trade 0.2.0,mock xtquant)

$ DEFAULT_DATA_PROVIDER=miniqmt venv310/bin/python -m pytest tests/portfolio -v
============================== 88 passed in 0.33s ==============================
  • 88 tests, 0 failures, 0 errors
  • test_factors.py: 36 (valuation + roic 含 Series 批量路径)
  • test_filters.py: 25 (ST/停牌/科创北交/次新/涨跌停 全覆盖)
  • test_provider.py: 12 (实例化/get_fundamentals_df/query dict 模式 filter+order_by+limit/set_data_provider 注入)
  • test_all_weather.py: 15 (initialize/prepare/stop_loss/monthly_adjustment 决策分支 + 4 个选股函数 + filter_roic)

覆盖率

模块 Stmts Miss Cover
factors/__init__.py 2 0 100%
factors/valuation.py 44 5 89%
factors/roic.py 52 0 100%
factors 合计 98 5 95%
filters.py 134 21 84%
providers/sanguo_fundamentals.py 322 147 54%
strategies/all_weather.py 336 98 71%
runner_backtest.py 97 97 0% (VPS)
runner_live.py 35 35 0% (VPS)
  • factors/filters 达标 80%+ (硬约束)
  • provider/策略覆盖核心 mock 路径,剩余未覆盖行 = jq query ORM 解析辅助函数 + 实盘 only 分支(需 VPS 跑)
  • runner 0% = 设计上需 VPS 连 miniQMT 跑,Mac 无 xtquant 无法驱动

关键设计决策

  1. ENV GUARD (VPS 实证发现的坑): bullet_trade.__init__ 默认 provider=jqdata → 硬 import jqdatasdk。所有入口(conftest/__init__/runner_*)在 import bullet_trade 前设 DEFAULT_DATA_PROVIDER=miniqmt。实际数据由 set_data_provider(SanguoMiniQmtProvider(...)) 覆盖,jqdatasdk 永不被装/调用。

  2. lazy import 容错: SanguoMiniQmtProvider 顶部 try: from bullet_trade... import MiniQMTProvider; except ImportError: MiniQMTProvider = object,Mac dev 环境装不全也能加载;xtquant 通过 self._ensure_xtdata() 函数内 import,可被 sys.modules['xtquant.xtdata'] = MagicMock 注入。

  3. factors/filters 零外部依赖: 纯函数只依赖 pandas/numpy,不 import bullet-trade/xtquant,任何环境都能单元测试。

  4. provider 两种入参: get_fundamentals_df(stocks, date) 策略直接用(pandas 风格筛选,避开 ORM);get_fundamentals(dict|query) 兼容聚宽 query ORM 子集(==/>/</between/in_/order_by/limit),复杂 filter 抛 NotImplementedError 标注。

  5. broker facade 注入: 策略不直接调 bullet_trade 顶层 API,所有 order/run_daily 通过 BrokerFacade dataclass 注入;runner 在回测/实盘装配具体实现,测试用 MagicMock。

已知限制(留 v2)

  1. PE/PB/PS/PCF 单期×4 近似 TTM: 对账聚宽时偏差(聚宽是滚 4 季度精确 TTM);相对排序影响小,绝对估值会偏。精确 TTM 滚 4 季度待 v2。
  2. jq query ORM 不完全解析: 仅支持 ==/>/</>=/<=/between/in_/order_by/limit,OR/跨表 join/自定义函数抛 NotImplementedError(标注)。策略已改用 get_fundamentals_df + pandas 筛选绕开此风险。
  3. actual_tax_rate 口径未对账: 启发式(|v|>1 视为百分数)处理 25/0.25 两种,NAN 时用 Income.inc_tax/profit_before_tax 兜底。茅台实盘该字段曾 NaN,真实 VPS 数据回来需复核。
  4. provider 覆盖率 54%: get_fundamentals 的 jq query 字符串解析辅助函数未单测(策略走 get_fundamentals_df 不触达)。VPS 跑回测时会自然覆盖,Mac 单测维持现状。
  5. balance 字段名不一致: xtquant 的 Balance 表字段名没标准(jqdatasdk 也漂移),代码加了多个 alias(cash_equivalents/monetary_funds,net_profit_excl_min_int/n_income),VPS 首跑前需打印实际字段名校准。
  6. ROIC 用单期 oper_profit: 聚宽 roic_ttm 是 TTM,这里用最近报告期单期,小幅偏差。

回测/实盘待办(待 VPS 交易日)

回测 (rsync VPS + miniQMT)

  1. rsync -avz sanguo_portfolio/ tests/portfolio/ vps:/path/to/sanguo_vnpy_v2/
  2. VPS: set DEFAULT_DATA_PROVIDER=miniqmt && python -m sanguo_portfolio.runner_backtest --start 2020-01-01 --end 2024-12-31 --cash 1000000
  3. 首 run 验证 Balance/Income/CashFlow/PershareIndex/Capital 字段名(打印一行的 fin_data[stock].keys()),与 provider 代码的 alias 对齐,如有偏差回到 sanguo_portfolio/providers/sanguo_fundamentals.py:_build_row 加 alias。
  4. 对账聚宽同期收益曲线(同 benchmark 000300.XSHG),偏差 > 5% 时排查:
    • PE/PB/PS/PCF 近似 TTM 偏差
    • actual_tax_rate 归一口径
    • ROIC 自算口径 vs roic_ttm
  5. 输出 docs/portfolio_backtest_result.md,提交回主分支。

实盘 (VPS miniQMT 直连)

  1. miniQMT 客户端已登录,确认 xtdata.connect() 返回 0
  2. set DEFAULT_DATA_PROVIDER=miniqmt && set MINIQMT_MARKET=SH && python -m sanguo_portfolio.runner_live
  3. 小资金起步: 1e6 元,观察首个交易日是否触发 prepare_stock_list(9:05) → monthly_adjustment(月初 9:30) → stop_loss(14:00)
  4. 实盘 1 个月跑通后再加仓,跟踪 vs 回测曲线偏差
  5. 异常处理:断线重连、订单超时、停牌拒单 → 视实盘表现补 broker_facade 包装