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

272 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_data`**PershareIndex**(现成 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(纯函数,易测)
```python
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
```python
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(核心)
```python
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
```python
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
```python
# 配 BulletTrade BacktestEngine
# set_data_provider(SanguoMiniQmtProvider({"mode":"backtest",...}))
# 加载 all_weather 策略, 设回测区间/benchmark/初始资金
# 跑回测, 输出收益曲线/选股名单/指标到 docs/portfolio_backtest_result.md
```
**注意**:回测要连 miniQMT(Mac 没有) → 回测脚本在 VPS 跑。
### runner_live.py(实盘就绪)
```python
# 配 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 包装