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
This commit is contained in:
2026-07-18 19:08:18 +08:00
parent 723e42ab36
commit a68cf4905e
20 changed files with 3821 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
# sanguo_portfolio 全天候策略 VPS 回测报告
> 生成日期:2026-07-18
> 环境:VPS49.232.102.198WindowsPython 3.10.11+ miniQMT 模拟端(userdata_mini
> 范围:沪深300 子集 39 只权重股,2025-04-17 → 2026-07-17(约 3 个月)
## 1. VPS pytest 结果
| 项目 | 值 |
|---|---|
| Python | CPython 3.10.11 (MSC v.1929 64 bit) @ C:\Python310\python.exe |
| pytest | 9.1.1VPS 预装) |
| bullet-trade | 0.9.2jqdatasdk 列为 required 但 env guard 跳过) |
| xtquant | 内置 xtdata,路径 C:\Python310\lib\site-packages\xtquant |
| miniQMT 数据路径 | C:\国金QMT交易端模拟\userdata_mini |
| **测试结果** | **88 passed, 1 warning in 1.24s** |
环境前置(**必须**,否则 `import bullet_trade` 报缺 jqdatasdk):
```cmd
set DEFAULT_DATA_PROVIDER=miniqmt
python -m pytest tests/portfolio -q
```
## 2. 字段校准前后对比(关键发现)
VPS 连 miniQMT 实测 600519.SH 茅台 PershareIndex/Balance/Capital/Income/CashFlow 实际字段名,
**发现 3 个严重不匹配**,全部修复。
### 2.1 修了哪些 alias
| 表 | sanguo 代码原用字段 | miniQMT 实际字段 | 修复方式 |
|---|---|---|---|
| PershareIndex | `roe` | `du_return_on_equity`(或 `equity_roe` | `_get_multi()` 多 alias 回退 |
| PershareIndex | `eps` | `s_fa_eps_basic` | 同上 |
| PershareIndex | `gross_profit_margin` | `sales_gross_profit`(或 `gross_profit` | 同上 |
| PershareIndex | `net_profit_margin` | `du_profit_rate`(或 `net_profit` | 同上 |
| PershareIndex | `inc_revenue_year_on_year` | `inc_revenue_rate` | 同上 |
| PershareIndex | `inc_operation_profit_year_on_year` | `inc_net_profit_rate` | 同上 |
| PershareIndex | `inc_total_revenue_year_on_year` | `inc_total_revenue_annual` | 同上 |
| Balance | `total_liability` | `tot_liab` | 同上 |
| Balance | `total_sheet_owner_equities` | `tot_shrhldr_eqy_excl_min_int`(或 `total_equity` | 同上 |
| Balance | `retained_profit` | `undistributed_profit` | 同上 |
| Balance | `short_loan` / `long_loan` | `shortterm_loan` / `long_term_loans` | 同上 |
### 2.2 三个严重 bug 修复
| bug | 修复前 | 修复后 |
|---|---|---|
| **Capital 单位** | `_to_float(...) * 10000.0`(按"万股"放大) | 直接用,实证单位 = 股(茅台 1,256,197,800 股 = 12.56 亿股,符合现实) |
| **日期格式** | `_to_date_str` 输出 `YYYY-MM-DD`xtdata `end_time` 报"结束时间错误" | 加 `_to_yyyymmdd()``YYYYMMDD` |
| **百分数口径** | miniQMT 返回 10.57=10.57%),策略阈值 `roe > 0.15`(=15%)按小数设计 → 全部误通过 | 加 `_pct_to_decimal()` 在 provider 输出归一到小数(0.1057),对齐聚宽 indicator 口径 |
### 2.3 其他口径偏差(已记录,未改)
| 项 | 现状 | 说明 |
|---|---|---|
| **ROE 口径** | miniQMT `du_return_on_equity` 是 YTD 累计(Q1=10.57%,年化约 30% | 策略阈值 `roe > 0.15` 是 TTM 年化口径,Q1 累计数据通过率低。**未自动年化**(季节性偏差大),策略层后续可改取 Q4 报告或自算 TTM |
| **PE 口径** | EPS 来自单季,×4 近似 TTM | 茅台 PE=14.4(实际 ~25),偏差源于 Q1 EPS × 4 不等于 TTM EPS(茅台 Q4 业绩最重) |
| **PS / PCF / ROIC** | Income/CashFlow trading hours 下载超时,oper_profit/cash_flow NaN | provider 加了 EPS × total_capital 兜底单季净利润,但 oper_profit/cash_flow 无替代源,PS/PCF/ROIC 实测 0% 非空 |
| **ROA 口径** | PershareIndex 无 roa 字段 | 用 ROE × (归母权益/总资产) 自算,茅台 0.0895(≈8.95% |
## 3. Provider 冒烟实证(600519.SH 茅台)
`provider.get_fundamentals_df(['600519.SH'], date='2026-07-17')` 返回:
| 字段 | 实测值 | 用户期望 | 验证 |
|---|---|---|---|
| roe(归一小数) | **0.1057** | ROE≈10% | ✅ |
| gross_profit_margin | **0.8976** | 毛利率≈92% | ✅(Q1 季节性略低) |
| eps(元) | 21.76 | 合理 | ✅ |
| market_cap(亿元) | **15663** | 1.5-2 万亿 | ✅(close=1253 |
| circulating_market_cap(亿元) | 15663 | 同上 | ✅ |
| pe_ratio | **14.4** | 实际 ~25,Q1×4 偏低 | ⚠️(口径偏差,见 2.3) |
| pb_ratio | 5.78 | 合理 | ✅ |
| roa(自算) | 0.0895 | 合理 | ✅ |
| total_liability(元) | 38.8B | 财报匹配 | ✅ |
| total_sheet_owner_equities(元) | 270.9B | 财报匹配 | ✅ |
| ps_ratio | 0.97 | Income 下载成功后能算 | ✅ |
| pcf_ratio | NaN | CashFlow 缺 | ❌ |
| roic | NaN | oper_profit 缺 | ❌ |
## 4. 短回测结果
**配置**39 只 HS300 权重股子集,2025-04-17 → 2026-07-1715 个月),单次选股快照,
等权持仓至期末。
### 4.1 字段非空率(39 只子集)
| 字段 | 非空数 | 占比 |
|---|---|---|
| roe / roa / market_cap / pb / net_profit_margin / inc_revenue_yoy | 38/39 | 97% |
| eps / pe_ratio | 37/39 | 95% |
| ps_ratio(依赖 Income | 38/39 | 97% |
| gross_profit_margin | 26/39 | 67%(银行/券商PershareIndex 该字段为 NaN |
| **pcf_ratio(依赖 CashFlow** | **0/39** | **0%** |
| **roic(依赖 oper_profit** | **0/39** | **0%** |
### 4.2 选股名单(4 个 filter 函数分别执行)
| 函数 | 选出 | 名单 |
|---|---|---|
| `small()` (roe>0.15, roa>0.10, market_cap asc) | 1 | 600585.XSHG 海螺水泥 |
| `big()` (pe∈0-30, ps∈0-8, pcf<10, eps>0.3, roe>0.1, npm>0.1, gpm>0.3, rev_yoy>0.25) | 0 | pcf NaN 被过滤掉,Q1 累计 roe 不达 0.1 年化阈值) |
| `bm()` (中市值价值股,pcf<4) | 0 | (同 pcf NaN 问题) |
| `roic_big()` (roic>0.08) | 0 | roic 全 NaN |
| **合并选股** | **1** | **600585.XSHG** |
**选股少的原因**
1. ROE 是 Q1 累计(10.57% 对茅台这种 TTM 30% 的股),归一到 0.1057 < 0.15 阈值,大部分被过滤
2. pcf_ratio 全 NaN,触发 `df["pcf_ratio"] < 10` 时 NaN 行被丢弃
3. roic 全 NaNroic_big 空产
### 4.3 收益曲线(等权持仓 2025-04-17 → 2026-07-17
| 项目 | 收益率 |
|---|---|
| 组合(600585 等权) | **-30.21%** |
| 基准 HS300 (000300.XSHG) | **+24.55%** |
| 超额收益 | -54.77% |
**说明**:单只选股 + 单期快照不构成有效策略回测,仅用于验证 pipeline 连通。
真实回测需要每月调仓 + 多期 + 完整 HS300 池 + 完整 TTM ROE/PCF/ROIC 数据。
## 5. 聚宽数值对账状态
| 项 | 状态 |
|---|---|
| **聚宽同期数值对账** | ❌ **缺基准**(用户不续费 jqdata,铁律不装 jqdatasdk |
| 自洽验证 | ✅ provider 连通 miniQMT,所有可计算字段(ROE/毛利率/PE/PB/PS/市值/负债/权益)数值合理 |
| 选股合理性 | ✅ 选股逻辑跑通,filter 函数无报错,每只股的财务指标符合行业常识 |
| 茅台 ROE/毛利率实证 | ✅ 10.57% / 89.76%Q1 累计),与公开财报一致 |
| 茅台 PE 实证 | ⚠️ 14.4Q1×4 近似 TTM 偏低,实际 ~25),口径差异已记录 |
## 6. 已修 / 待修清单
### ✅ 已修(本次提交)
1. provider 字段 alias11 个字段加 `_get_multi()` 多 alias 回退
2. Capital 单位 bug:移除 ×10000miniQMT 实际返回股数)
3. 日期格式:`_to_yyyymmdd()` 转 YYYYMMDD 给 xtdata `end_time`
4. 百分数归一:PershareIndex 的 ROE/ROA/毛利率/净利率/同比全部 ÷100 到小数口径
5. Income 空表兜底:EPS × total_capital 算单季净利润(calc_pe 内 ×4 近似 TTM
6. ROA 自算:ROE × (归母权益 / 总资产)
7. 收窄 `download_financial_data` 默认表清单到 `['PershareIndex', 'Balance', 'Capital']`trading hours Income/CashFlow 常超时)
8. conftest Capital mock 单位对齐(万股 → 股)
9. test_provider 过滤断言对齐归一后口径(`>30``>0.3`
### ⚠️ 待修(策略层,下个迭代)
1. **ROE TTM 化**:当前 Q1 累计导致 roe>0.15 过滤过严,应取 Q4 报告或自算滚 4 季度 TTM
2. **PCF / ROIC 数据源**CashFlow/oper_profit 全空,考虑:
- 盘后批量下载 CashFlow 表(trading hours 超时)
- 用 PershareIndex 的 `s_fa_cfps` × total_capital 兜底经营现金流
-`net_profit / (1 - tax_rate)` 兜底 oper_profit
3. **真实回测驱动**:当前 mini_backtest.py 是单期快照;接 bullet-trade BacktestEngine 跑月度调仓序列需另做(runner_backtest.py 已写框架,需对齐 BT 0.9.2 API
4. ** benchmark 沪深300 完整 300 只**:当前子集 39 只只验证 pipeline,扩到全 300 只再跑完整调仓
## 7. 复现命令(VPS
```cmd
:: 1. 同步代码(Mac 端)
cd ~/.openclaw/sanguo_projects/sanguo_vnpy_v2
tar -czf /tmp/sp.tar.gz --exclude='__pycache__' --exclude='*.pyc' sanguo_portfolio/ tests/portfolio/
scp /tmp/sp.tar.gz 49.232.102.198:C:/sanguo_vnpy_v2/sanguo_portfolio_sync.tar.gz
:: 2. VPS 端解压 + 测试
ssh 49.232.102.198
cd C:\sanguo_vnpy_v2
tar -xzf sanguo_portfolio_sync.tar.gz
set DEFAULT_DATA_PROVIDER=miniqmt
C:\Python310\python.exe -m pytest tests/portfolio -q
:: 3. provider 冒烟(茅台)
C:\Python310\python.exe -X utf8 _smoke_provider.py
:: 4. 预下载 HS300 子集 + 回测
C:\Python310\python.exe -X utf8 _predl.py
C:\Python310\python.exe -X utf8 _mini_backtest.py
```
## 8. 关键代码位置
- provider 主文件:`sanguo_portfolio/providers/sanguo_fundamentals.py`
- 策略层:`sanguo_portfolio/strategies/all_weather.py`
- 因子(ROIC/估值自算):`sanguo_portfolio/factors/{roic,valuation}.py`
- 过滤器:`sanguo_portfolio/filters.py`
- 回测入口(框架):`sanguo_portfolio/runner_backtest.py`(接 BacktestEngine 待迭代)
- 简化回测驱动(本次用):VPS `_mini_backtest.py`(探针脚本,未提交)
+53
View File
@@ -0,0 +1,53 @@
# sanguo_portfolio 实盘启动手册 (AllWeather 全天候轮动)
**状态**:代码就绪,等交易日首跑(周六休市)。回测验证结论见 `portfolio_backtest_result.md`T9 完成后补)。
## 前置确认(VPS 49.232.102.198
- [ ] miniQMT 客户端运行中(userdata_mini = `C:\国金QMT交易端模拟\userdata_mini`),交易账号已登录
- [ ] bullet-trade 0.9.2 已装(VPS),`jqdatasdk` 未装(走 env 路径)
- [ ] sanguo_portfolio/ 已同步到 VPST9 agent 同步过,若 runner_live.py 有更新重新 scp
- [ ] xtquant 可用(miniQMT 提供)
## 启动(VPS Windows cmd
```bat
cd C:\sanguo_vnpy_v2 (或 VPS 项目根)
set DEFAULT_DATA_PROVIDER=miniqmt
set MINIQMT_MARKET=SH
set SANGUO_QMT_ACCOUNT=66639661
set SANGUO_QMT_PATH=C:\国金QMT交易端模拟\userdata_mini
python -m sanguo_portfolio.runner_live
```
- `DEFAULT_DATA_PROVIDER=miniqmt` 必设(避免 bullet-trade 模块加载强制 import jqdatasdk
- `SANGUO_QMT_ACCOUNT` 必设(runner_live 缺它拒绝启动,防误下单)
- 初始资金 1,000,000(小仓位起步,runner_live 硬编码,首跑后按需调)
## 触发时点(BulletTrade scheduler 驱动)
| 时间 | 函数 | 动作 |
|---|---|---|
| 09:05 | prepare_stock_list | 记昨日涨停股、刷新持仓列表 |
| 月初第1交易日 09:30 | monthly_adjustment | 大小盘轮动择时 + 4 选股函数选 3-9 只 + ETF 兜底 + 调仓 |
| 14:00 | stop_loss | 昨日涨停今日打开卖 / 亏损 8% 止损 / 补跌加仓 |
## 观察点(首跑重点盯)
1. **QmtBroker connect**:日志 `QmtBroker 装配 account=...` 后应见连接成功;若 LiveEngine 未自动 connect,首跑需在 run_live 显式 `broker.connect()`(已知风险点,首跑验证)
2. **字段名**provider 取 PershareIndex/Balance 实际字段名(T9 回测校准过 alias,若 VPS 实盘仍报 KeyError,对照 portfolio_backtest_result.md 字段校准表)
3. **首笔调仓**:月初 monthly_adjustment 触发,看 target_list 是否合理(3-9 只 + 可能 ETF),order_target_value 下单手数对不对(A股×100)
4. **涨跌停过滤**:涨停买不进/跌停卖不出是否正确跳过
## 风控
- 小仓位 1e6 起步(全天候策略最多持 9 只股票 + ETF)
- 涨停止损 + 8% 止损内置(stop_loss
- T+1 自动扣减(BulletTrade A股适配)
- **首跑建议**:非月初启动,先观察 prepare/stop_loss 触发不调仓;月初再验证 monthly_adjustment
## 等交易日
今天(2026-07-18 周六)休市,真实成交做不了。代码已就绪,**周一(7/20)开盘后首跑**。首跑先小仓位 + 非月初观察 scheduler,确认连通后再等月初验证完整调仓。
## 回测验证结论
T9 agent 完成后,从 portfolio_backtest_result.md 摘要:策略是否跑通、选股名单合理性、字段校准结果、聚宽数值对账缺基准标注)
## 已知限制
- PE/PB/PS/PCF 单期×4 近似 TTM(对账聚宽有偏差,精确 TTM 留 v2)
- ROIC 用单期 oper_profitvs 聚宽 roic_ttm
- jq query ORM 仅支持 ==/>/</between/in_/order_by/limit 子集
- 聚宽数值对账缺基准(用户不续费 jqdata),仅自洽验证
+271
View File
@@ -0,0 +1,271 @@
# 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 包装
+32
View File
@@ -0,0 +1,32 @@
"""sanguo_portfolio:聚宽全天候轮动策略搬到 BulletTrade 框架。
子模块:
- ``factors`` 纯函数估值/ROIC 因子(无外部依赖)
- ``filters`` ST/停牌/科创北交/次新/涨跌停 过滤(无外部依赖)
- ``providers`` SanguoMiniQmtProvider(继承 bullet-trade MiniQMTProvider)
- ``strategies`` AllWeatherStrategy(聚宽 post48819 翻译)
ENV GUARD(bullet-trade 0.9.2 坑):
- ``import bullet_trade`` 时 default provider 是 jqdata,会强制 ``import jqdatasdk``,
本地/服务器都没装(用户铁律不用 jqdata 付费)。
- 在任何 ``import bullet_trade`` **之前**设 ``DEFAULT_DATA_PROVIDER=miniqmt``,
default provider 切到 MiniQMTProvider,跳过 jqdatasdk。
- 实际数据走 ``set_data_provider(SanguoMiniQmtProvider(...))`` 覆盖。
"""
import os
# 默认 provider 切 miniqmt,避开 jqdatasdk 硬 import(必须早于任何 bullet_trade import)
os.environ.setdefault("DEFAULT_DATA_PROVIDER", "miniqmt")
from . import factors, filters
from .providers import SanguoMiniQmtProvider
from .strategies import AllWeatherConfig, AllWeatherStrategy, BrokerFacade
__all__ = [
"factors",
"filters",
"SanguoMiniQmtProvider",
"AllWeatherStrategy",
"AllWeatherConfig",
"BrokerFacade",
]
+7
View File
@@ -0,0 +1,7 @@
"""sanguo_portfolio 因子层。
纯函数,无 bullet-trade / xtquant 依赖,可在任意环境单元测试。
"""
from . import roic, valuation
__all__ = ["roic", "valuation"]
+124
View File
@@ -0,0 +1,124 @@
"""ROIC(Return on Invested Capital)= NOPAT / 投入资本。
聚宽因子库 ``roic_ttm`` 在 miniQMT 没有直接对应字段,需自算。
公式:NOPAT = 营业利润 × (1 - 实际税率);投入资本 = 归母权益 + 有息负债 - 现金等价物。
字段来源(miniQMT 已实证可用):
- ``oper_profit`` ← Income 表 营业利润
- ``actual_tax_rate`` ← PershareIndex 实际税率(口径见下)
- ``tot_shrhldr_eqy`` ← Balance 表 归母权益(合计权益 - 少数股东权益)
- ``interest_bearing_debt`` ← Balance 表(短期借款 + 长期借款 + 应付债券)
- ``cash_equivalents`` ← Balance 表 货币资金
actual_tax_rate 口径警告:PershareIndex 的 ``actual_tax_rate`` 实证时茅台 NaN,
且有"百分数(20.0)""小数(0.2)"两种口径风险。这里用启发式:
- 若 |值| > 1,认为是百分数 → 除以 100
- 若 |值| <= 1,认为是小数 → 直接用
- 若 NaN,返回 NaN(不强行兜底,留给上层用 Income.inc_tax / 利润总额 兜底)
"""
from __future__ import annotations
import math
from typing import Optional, Union
import numpy as np
import pandas as pd
NumberLike = Union[float, int, "pd.Series", "np.ndarray"]
_EPS = 1e-9
def normalize_tax_rate(actual_tax_rate: Optional[NumberLike]) -> NumberLike:
"""把 actual_tax_rate 归一化到小数(0.2 = 20%)。
启发式:|v| > 1 → 原值是百分数,除以 100;|v| <= 1 → 已经是小数。
NaN 透传(让上层用兜底算法处理)。
"""
if actual_tax_rate is None:
return float("nan")
if isinstance(actual_tax_rate, (pd.Series, np.ndarray)):
arr = pd.Series(actual_tax_rate) if isinstance(actual_tax_rate, np.ndarray) else actual_tax_rate
# |v|>1 视为百分数;注意 NaN 比较返回 False,自动透传
need_div = arr.abs() > 1
return arr.where(~need_div, arr / 100.0)
if isinstance(actual_tax_rate, float) and math.isnan(actual_tax_rate):
return float("nan")
if abs(actual_tax_rate) > 1:
return actual_tax_rate / 100.0
return float(actual_tax_rate)
def fallback_tax_rate(
inc_tax: Optional[NumberLike], profit_before_tax: Optional[NumberLike]
) -> NumberLike:
"""actual_tax_rate NaN 时的兜底:所得税 / 利润总额。
聚宽口径:``actual_tax_rate = income_tax / profit_before_tax``(利润总额为正时)。
利润总额 <= 0 时返回 0(避免负税率扭曲 NOPAT)。
"""
if inc_tax is None or profit_before_tax is None:
return float("nan")
if isinstance(profit_before_tax, (pd.Series, np.ndarray)):
arr = pd.Series(profit_before_tax) if isinstance(profit_before_tax, np.ndarray) else profit_before_tax
tax = pd.Series(inc_tax) if isinstance(inc_tax, np.ndarray) else inc_tax
# 利润总额>0 才算,否则 0
safe = arr.where(arr > _EPS, _EPS)
rate = tax / safe
return rate.where(arr > _EPS, 0.0)
if profit_before_tax <= _EPS:
return 0.0
return inc_tax / profit_before_tax
def calc_roic(
oper_profit: NumberLike,
actual_tax_rate: Optional[NumberLike],
tot_shrhldr_eqy: NumberLike,
interest_bearing_debt: NumberLike,
cash_equivalents: NumberLike,
*,
inc_tax: Optional[NumberLike] = None,
profit_before_tax: Optional[NumberLike] = None,
) -> NumberLike:
"""ROIC = NOPAT / 投入资本。
NOPAT = 营业利润 × (1 - 实际税率)
投入资本 = 归母权益 + 有息负债 - 现金等价物
参数:
oper_profit: 营业利润(单期,**不是 TTM**,对账聚宽 roic_ttm 时注意)
actual_tax_rate: 实际税率(百分数或小数都行,内部归一)
tot_shrhldr_eqy: 归母权益(时点值)
interest_bearing_debt: 有息负债(短期+长期借款+应付债券)
cash_equivalents: 货币资金
inc_tax / profit_before_tax: 可选兜底,actual_tax_rate NaN 时启用
分母(投入资本) <= 0 时返回 NaN(避免负/零投入资本产生误导性 ROIC)。
"""
# Step 1: 税率(actual_tax_rate → 兜底)
rate = normalize_tax_rate(actual_tax_rate)
if isinstance(rate, (pd.Series, np.ndarray)):
arr = pd.Series(rate)
if inc_tax is not None and profit_before_tax is not None:
fb = fallback_tax_rate(inc_tax, profit_before_tax)
fb = pd.Series(fb) if isinstance(fb, np.ndarray) else fb
rate = arr.where(arr.notna(), fb)
else:
if (rate != rate) and inc_tax is not None and profit_before_tax is not None:
rate = fallback_tax_rate(inc_tax, profit_before_tax)
nopat = oper_profit * (1 - rate)
invested_capital = tot_shrhldr_eqy + interest_bearing_debt - cash_equivalents
if isinstance(invested_capital, (pd.Series, np.ndarray)):
arr = pd.Series(invested_capital) if isinstance(invested_capital, np.ndarray) else invested_capital
safe = arr.where(arr > _EPS, _EPS)
result = nopat / safe
return result.where(arr > _EPS, float("nan"))
if invested_capital <= _EPS:
return float("nan")
return nopat / invested_capital
__all__ = ["calc_roic", "normalize_tax_rate", "fallback_tax_rate"]
+129
View File
@@ -0,0 +1,129 @@
"""估值因子:市值 + PE/PB/PS/PCF 自算。
为什么自己算而不是直接读 miniQMT:
- miniQMT 的 PershareIndex 给的是现成指标(ROE/ROA/EPS/毛利率...),**没有** valuation
表(PE/PB/PS/PCF/总市值/流通市值)。聚宽的 valuation 表是单独一张快照表,miniQMT
对应能力 = ``xtdata.get_market_data_ex`` 拿 close + ``Capital`` 拿股本 → 乘出来。
- 单期×4 近似 TTM:报告期利润/现金流是单季累计值,直接×4 当 TTM 用是**近似口径**,
对账聚宽时会偏差(聚宽是滚 4 季度精确 TTM)。回测对相对排序影响小,绝对估值会偏。
精确 TTM 滚 4 季度留 v2。
口径约定(字段名对齐聚宽 valuation 表):
- ``market_cap`` → 总市值(亿元) = close × total_capital / 1e8
- ``circulating_market_cap`` → 流通市值(亿元) = close × circulating_capital / 1e8
- ``pe_ratio`` → close × total_capital / (归母净利润 × 4) 近似 TTM
- ``pb_ratio`` → close × total_capital / 归母净资产
- ``ps_ratio`` → close × total_capital / (营业收入 × 4) 近似 TTM
- ``pcf_ratio`` → close × total_capital / (经营现金流 × 4) 近似 TTM
所有 ``calc_*`` 接受标量或 pandas Series/numpy array(靠 pandas/numpy 广播)。
"""
from __future__ import annotations
import math
from typing import Union
import numpy as np
import pandas as pd
NumberLike = Union[float, int, "pd.Series", "np.ndarray"]
# 分母下限,避免除零产生 inf 污染后续排序。1e-9 远小于任何实际财务数据。
_EPS = 1e-9
def calc_market_cap(close: NumberLike, total_capital: NumberLike) -> NumberLike:
"""总市值(元) = close × 总股本。聚宽 ``market_cap`` 是亿元,见 ``to_yi``。"""
return close * total_capital
def calc_circulating_market_cap(
close: NumberLike, circulating_capital: NumberLike
) -> NumberLike:
"""流通市值(元) = close × 流通股本。聚宽 ``circulating_market_cap`` 是亿元。"""
return close * circulating_capital
def _safe_denominator(value: NumberLike) -> NumberLike:
"""分母保护:把 |x|<eps 的值替换成带符号的 eps,保留正负号。"""
if isinstance(value, (pd.Series, pd.DataFrame)):
sign = np.sign(value)
sign = sign.where(sign != 0, 1.0)
return sign * value.abs().where(value.abs() > _EPS, _EPS)
if isinstance(value, np.ndarray):
sign = np.sign(value)
sign[sign == 0] = 1.0
abs_v = np.abs(value)
return sign * np.where(abs_v > _EPS, abs_v, _EPS)
if value is None or (isinstance(value, float) and math.isnan(value)):
return _EPS
abs_v = abs(value)
return value if abs_v > _EPS else (1.0 if value >= 0 else -1.0) * _EPS
def calc_pe(
close: NumberLike,
net_profit_excl_min_int: NumberLike,
total_capital: NumberLike,
) -> NumberLike:
"""市盈率(近似 TTM) = close × total_capital / (归母净利润 × 4)。
口径:``net_profit_excl_min_int`` 是最近报告期**单期**归母净利润,
×4 近似 TTM(标注"近似口径,对账聚宽时校准")。
亏损股返回负 PE,保留排序信息(聚宽也是负值)。
"""
numerator = close * total_capital
denominator = _safe_denominator(net_profit_excl_min_int) * 4
return numerator / denominator
def calc_pb(
close: NumberLike,
tot_shrhldr_eqy_excl_min_int: NumberLike,
total_capital: NumberLike,
) -> NumberLike:
"""市净率 = close × total_capital / 归母净资产。
口径:净资产是时点存量,不需要×4。
"""
numerator = close * total_capital
denominator = _safe_denominator(tot_shrhldr_eqy_excl_min_int)
return numerator / denominator
def calc_ps(
close: NumberLike,
revenue: NumberLike,
total_capital: NumberLike,
) -> NumberLike:
"""市销率(近似 TTM) = close × total_capital / (营业收入 × 4)。"""
numerator = close * total_capital
denominator = _safe_denominator(revenue) * 4
return numerator / denominator
def calc_pcf(
close: NumberLike,
net_oper_cash_flow: NumberLike,
total_capital: NumberLike,
) -> NumberLike:
"""市现率(近似 TTM) = close × total_capital / (经营现金流 × 4)。"""
numerator = close * total_capital
denominator = _safe_denominator(net_oper_cash_flow) * 4
return numerator / denominator
def to_yi(value: NumberLike) -> NumberLike:
"""元 → 亿元(聚宽 valuation 口径)。"""
return value / 1e8
__all__ = [
"calc_market_cap",
"calc_circulating_market_cap",
"calc_pe",
"calc_pb",
"calc_ps",
"calc_pcf",
"to_yi",
]
+226
View File
@@ -0,0 +1,226 @@
"""A 股选股过滤器(ST/停牌/科创北交/次新/涨跌停)。
聚宽全天候策略用到的 filter_* 函数的本地实现。
聚宽原版用 ``get_current_data()`` 拿快照,我们用注入的 provider 拿同样的字段。
设计:接受一个 ``provider`` 参数(duck typing),不依赖具体类。
provider 需要提供:
- ``get_security_info(code, date)`` → {"display_name", "name", "start_date", ...}
- ``get_current_tick(code)`` 或 ``get_live_current(code)`` → {"paused", "high_limit", "low_limit", "last_price"}
- ``get_price(code, end_date, frequency, fields, count, panel=False)`` → DataFrame
bullet-trade 的 MiniQMTProvider 全部满足,且本模块测试用 MagicMock 也能跑。
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Any, Iterable, List, Optional, Sequence
logger = logging.getLogger(__name__)
def _safe_call(fn: Any, *args, **kwargs) -> Any:
"""容错调用:provider 或 xtquant 异常时返回 None,不污染整批过滤。"""
try:
return fn(*args, **kwargs)
except Exception as exc:
logger.debug("provider 调用失败 %s %s: %s", args, kwargs, exc)
return None
def filter_st_stock(stocks: Iterable[str], provider: Any) -> List[str]:
"""过滤 ST/* /退市股:名字含 'ST' / '*' / '退'
provider 用 ``get_security_info(code)`` 拿 ``display_name``。
取不到名字时**保留**该股(宁错过不误杀,反向错过只是少买)。
"""
result: List[str] = []
for stock in stocks:
info = _safe_call(provider.get_security_info, stock)
if not info:
result.append(stock)
continue
name = (info.get("display_name") or info.get("name") or "")
if "ST" in name or "*" in name or "退" in name:
continue
result.append(stock)
return result
def filter_paused_stock(stocks: Iterable[str], provider: Any) -> List[str]:
"""过滤当日停牌股。
provider 取 ``get_live_current`` 或 ``get_current_tick`` 的 ``paused`` 字段。
取不到快照时保留该股(回测场景可能没快照,不按停牌处理)。
"""
result: List[str] = []
for stock in stocks:
tick = _safe_call(_get_tick, provider, stock)
if tick and tick.get("paused"):
continue
result.append(stock)
return result
def _get_tick(provider: Any, stock: str) -> Optional[dict]:
"""优先 get_live_current(miniQMT),再退 get_current_tick(base 可选)。"""
if hasattr(provider, "get_live_current"):
return provider.get_live_current(stock)
if hasattr(provider, "get_current_tick"):
return provider.get_current_tick(stock)
return None
def filter_kcbj_stock(stocks: Iterable[str]) -> List[str]:
"""过滤科创北交所:代码 4/8(北交)/68(科创)/3(创业板)开头。
注意:聚宽原版连 300xxx 创业板也一并过滤掉,这里保持一致。
"""
result: List[str] = []
for stock in stocks:
# 聚宽 code 格式 "600519.XSHG",取 "." 前的纯代码
code = stock.split(".", 1)[0] if "." in stock else stock
if not code:
continue
first = code[0]
if first in ("4", "8", "3"):
continue
if code[:2] == "68":
continue
result.append(stock)
return result
def filter_new_stock(
stocks: Iterable[str],
provider: Any,
today: Any,
days: int = 375,
) -> List[str]:
"""过滤次新股:上市 < ``days`` 天(默认 375 ≈ 1 年+少量缓冲)。
provider 用 ``get_security_info(code).start_date`` 拿上市日。
``today`` 接受 datetime/date/str(YYYY-MM-DD),聚宽风格 ``context.previous_date``。
"""
today_dt = _coerce_datetime(today)
result: List[str] = []
for stock in stocks:
info = _safe_call(provider.get_security_info, stock)
if not info:
result.append(stock)
continue
start = info.get("start_date")
if not start:
result.append(stock)
continue
start_dt = _coerce_datetime(start)
if start_dt is None:
result.append(stock)
continue
if (today_dt - start_dt).days < days:
continue
result.append(stock)
return result
def filter_limitup_stock(
stocks: Iterable[str],
provider: Any,
positions: Optional[Sequence[str]] = None,
last_prices: Optional[dict] = None,
) -> List[str]:
"""过滤涨停股(不买涨停):最新价 >= 涨停价。
持仓中的涨停股**不过滤**(涨停还能继续持有的)。
``last_prices`` 可选:外部预取的最新价 dict {code: price},省去逐只查 tick。
"""
pos_set = set(positions or [])
prices = last_prices or {}
result: List[str] = []
for stock in stocks:
if stock in pos_set:
result.append(stock)
continue
price = prices.get(stock)
high_limit = None
if price is None:
tick = _safe_call(_get_tick, provider, stock) or {}
price = tick.get("last_price")
high_limit = tick.get("high_limit")
else:
tick = _safe_call(_get_tick, provider, stock) or {}
high_limit = tick.get("high_limit")
if price is None or high_limit is None:
result.append(stock)
continue
if price >= high_limit:
continue
result.append(stock)
return result
def filter_limitdown_stock(
stocks: Iterable[str],
provider: Any,
positions: Optional[Sequence[str]] = None,
last_prices: Optional[dict] = None,
) -> List[str]:
"""过滤跌停股(不卖跌停):最新价 <= 跌停价。
持仓中的跌停股**不过滤**(跌停要能卖才平)。
"""
pos_set = set(positions or [])
prices = last_prices or {}
result: List[str] = []
for stock in stocks:
if stock in pos_set:
result.append(stock)
continue
price = prices.get(stock)
low_limit = None
if price is None:
tick = _safe_call(_get_tick, provider, stock) or {}
price = tick.get("last_price")
low_limit = tick.get("low_limit")
else:
tick = _safe_call(_get_tick, provider, stock) or {}
low_limit = tick.get("low_limit")
if price is None or low_limit is None:
result.append(stock)
continue
if price <= low_limit:
continue
result.append(stock)
return result
def _coerce_datetime(value: Any) -> Optional[datetime]:
"""str/date/datetime → datetime,None/异常 → None。"""
if value is None:
return None
if isinstance(value, datetime):
return value
try:
from datetime import date as _date
if isinstance(value, _date):
return datetime(value.year, value.month, value.day)
except Exception:
pass
if isinstance(value, str):
try:
return datetime.fromisoformat(value[:10])
except ValueError:
return None
return None
__all__ = [
"filter_st_stock",
"filter_paused_stock",
"filter_kcbj_stock",
"filter_new_stock",
"filter_limitup_stock",
"filter_limitdown_stock",
]
+4
View File
@@ -0,0 +1,4 @@
"""sanguo_portfolio 数据 provider 层。"""
from .sanguo_fundamentals import SanguoMiniQmtProvider
__all__ = ["SanguoMiniQmtProvider"]
@@ -0,0 +1,649 @@
"""SanguoMiniQmtProvider:继承 MiniQMTProvider,补齐 ``get_fundamentals``。
BulletTrade 的 MiniQMTProvider 实现了行情/成分/涨跌停/证券信息,**唯一缺口**是
base.py:159 的 ``get_fundamentals``(默认抛 NotImplementedError)。本子类填这个缺口。
数据源映射(miniQMT 实证 2026-07-18,见 docs/portfolio_backtest_result.md):
- ``xtdata.get_financial_data(stock_list)`` → dict[stock][table_name] → DataFrame
table_name: 'PershareIndex' / 'Capital' / 'Balance' / 'Income' / 'CashFlow'
- ``xtdata.get_market_data_ex`` → close 行情(**end_time 必须 YYYYMMDD,带 dash 报错**)
- ``Capital[total_capital/circulating_capital]`` 单位 = **股**(不是万股),不需 ×10000
字段名差异(miniQMT 实际 vs 聚宽/本 provider 历史 alias):
- PershareIndex: du_return_on_equity→roe, s_fa_eps_basic→eps,
sales_gross_profit→gross_profit_margin, du_profit_rate→net_profit_margin,
inc_revenue_rate→inc_revenue_year_on_year,
inc_net_profit_rate→inc_operation_profit_year_on_year,
inc_total_revenue_annual→inc_total_revenue_year_on_year
- Balance: tot_liab→total_liability,
tot_shrhldr_eqy_excl_min_int→total_sheet_owner_equities,
undistributed_profit→retained_profit,
shortterm_loan→short_loan, long_term_loans→long_loan
- ROA:PershareIndex 没有,用 ROE × (equity / total_assets) 自算
聚宽 query(...) ORM 太复杂,这里支持两种入参:
1. dict: ``{'stocks': [...], 'date': 'YYYY-MM-DD'}`` 直接返合并 DataFrame(推荐,策略用)
2. jq-style query_object: 支持 ``filter(==/>/</between/in_)``/``order_by``/``limit``
复杂 filter 抛 NotImplementedError(标注清楚)
Mac 没有 xtquant,所有 xtquant 调用通过 ``self._ensure_xtdata()``,可被 mock 注入。
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional, Union
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
# bullet-trade 可能正在装,容错 import
try:
from bullet_trade.data.providers.miniqmt import MiniQMTProvider # type: ignore
_HAS_BT_BASE = True
_BT_IMPORT_ERROR: Optional[Exception] = None
except ImportError as _e: # Mac dev 环境可能未装,允许模块加载
MiniQMTProvider = object # type: ignore[misc,assignment]
_HAS_BT_BASE = False
_BT_IMPORT_ERROR = _e
# 聚宽 valuation/indicator/balance 列名 → 我们合并 DataFrame 的列名
# (统一用聚宽列名,方便策略层直接 pandas 筛选)
JQ_COLUMN_ALIASES: Dict[str, str] = {
# valuation 表(自算)
"market_cap": "market_cap",
"circulating_market_cap": "circulating_market_cap",
"pe_ratio": "pe_ratio",
"pb_ratio": "pb_ratio",
"ps_ratio": "ps_ratio",
"pcf_ratio": "pcf_ratio",
"code": "code",
# indicator(PershareIndex 直接拿)
"roe": "roe",
"roa": "roa",
"eps": "eps",
"gross_profit_margin": "gross_profit_margin",
"net_profit_margin": "net_profit_margin",
"inc_revenue_year_on_year": "inc_revenue_year_on_year",
"inc_operation_profit_year_on_year": "inc_operation_profit_year_on_year",
"inc_total_revenue_year_on_year": "inc_total_revenue_year_on_year",
# balance(合并表)
"total_liability": "total_liability",
"total_sheet_owner_equities": "total_sheet_owner_equities",
"retained_profit": "retained_profit",
}
class SanguoMiniQmtProvider(MiniQMTProvider): # type: ignore[misc]
"""miniQMT + 自算估值/ROIC 的 fundamentals provider。
继承 MiniQMTProvider 的行情/成分/证券信息/涨跌停能力,**只补 get_fundamentals**。
策略层用 ``get_fundamentals_df(stocks, date)`` 直接拿合并 DataFrame 做 pandas 筛选,
避开解析聚宽 query ORM。
"""
name: str = "sanguo_miniqmt"
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
if not _HAS_BT_BASE:
raise RuntimeError(
f"bullet-trade 未安装,SanguoMiniQmtProvider 无法继承 MiniQMTProvider: "
f"{_BT_IMPORT_ERROR}"
)
super().__init__(config or {})
# ------------------------ 主入口 ------------------------
def get_fundamentals(
self,
query_object: Any,
date: Optional[Union[str, datetime]] = None,
statDate: Optional[str] = None,
) -> pd.DataFrame:
"""聚宽风格 query + 直接 dict 两种入参。
Args:
query_object:
- dict: ``{'stocks': [...], 'date': 'YYYY-MM-DD'}`` 直接返合并 DataFrame
- 其它: 当作 jq query ORM 解析(支持 filter/order_by/limit 子集)
date: 查询日期(YYYY-MM-DD 或 datetime),用于取 close 和报告期
statDate: 聚宽 statDate 风格('2023Q3'/'2023'),暂不支持,传则忽略并告警
Returns:
聚宽 get_fundamentals 语义的 DataFrame:列见 ``JQ_COLUMN_ALIASES``。
"""
if statDate is not None:
logger.warning("statDate=%s 暂不支持,忽略,用最近报告期", statDate)
# 入参分支 1: dict 直接给 stocks
if isinstance(query_object, dict):
stocks = list(query_object.get("stocks") or [])
query_date = query_object.get("date") or date
df = self.get_fundamentals_df(stocks, query_date)
extra_filter = query_object.get("filter")
if extra_filter and callable(extra_filter):
df = df[extra_filter(df)]
order_by = query_object.get("order_by")
if order_by:
df = _apply_order_by(df, order_by)
limit = query_object.get("limit")
if isinstance(limit, int) and limit > 0:
df = df.head(limit)
return df
# 入参分支 2: 当作 jq-style query ORM
df = self._resolve_stocks_from_query(query_object, date)
return df
# ------------------------ 策略层便捷方法 ------------------------
def get_fundamentals_df(
self,
stocks: List[str],
date: Optional[Union[str, datetime]] = None,
) -> pd.DataFrame:
"""合并 PershareIndex + Balance + Income + CashFlow + Capital + close。
返回 DataFrame 每行一只股票,index 是 jq-style code(如 ``600519.XSHG``)。
列含:code/market_cap/circulating_market_cap/pe_ratio/pb_ratio/ps_ratio/pcf_ratio
+ roe/roa/eps/gross_profit_margin/net_profit_margin/inc_revenue_year_on_year/
inc_operation_profit_year_on_year/inc_total_revenue_year_on_year
+ total_liability/total_sheet_owner_equities/retained_profit
+ roic(自算)+归母净利润/营收/经营现金流(供策略再算其它因子)
"""
if not stocks:
return pd.DataFrame(columns=list(JQ_COLUMN_ALIASES.values()))
xt = self._ensure_xtdata()
date_str = _to_date_str(date)
# Step 1: 拉财务数据(miniQMT 返回 dict[stock_code] -> dict[table] -> DataFrame)
# 成分股用 jq code,xtdata 要 QMT 风格("600519.SH"),通过 _normalize_security_code
qmt_stocks = [self._normalize_security_code(s) for s in stocks]
try:
if self.auto_download:
try:
# 实证 signature: download_financial_data(stock_list, table_list=[])
# trading hours Income/CashFlow 常超时,主拿 PershareIndex/Balance/Capital
xt.download_financial_data(
qmt_stocks,
["PershareIndex", "Balance", "Capital"],
)
except Exception as exc:
logger.debug("download_financial_data 失败(继续读缓存): %s", exc)
fin_data = xt.get_financial_data(qmt_stocks)
except Exception as exc:
logger.warning("get_financial_data 失败,返空表: %s", exc)
return pd.DataFrame(columns=list(JQ_COLUMN_ALIASES.values()))
# Step 2: 拉当日收盘(用 date_str 或最近一日)
close_map = self._fetch_close(qmt_stocks, date_str)
# Step 3: 组装每行
rows: List[Dict[str, Any]] = []
for jq_code, qmt_code in zip(stocks, qmt_stocks):
stock_fin = fin_data.get(qmt_code) or fin_data.get(jq_code) or {}
close = close_map.get(qmt_code) or close_map.get(jq_code)
row = self._build_row(jq_code, qmt_code, stock_fin, close)
rows.append(row)
df = pd.DataFrame(rows)
if "code" in df.columns:
df = df.set_index("code", drop=False)
return df
# ------------------------ 内部组装 ------------------------
def _build_row(
self,
jq_code: str,
qmt_code: str,
stock_fin: Dict[str, Any],
close: Optional[float],
) -> Dict[str, Any]:
"""合并 PershareIndex(最新一行) + Balance + Income + CashFlow + Capital + close。"""
from .. import factors # lazy import,避免循环
row: Dict[str, Any] = {"code": jq_code}
# Capital(股本,实证单位 = 股,不再 ×10000)
capital = _latest_row(stock_fin.get("Capital"))
total_capital = _to_float(_get(capital, "total_capital"))
circulating_capital = _to_float(_get(capital, "circulating_capital"))
# Balance(实测字段名 tot_liab / tot_shrhldr_eqy_excl_min_int / undistributed_profit /
# shortterm_loan / long_term_loans;历史 alias 用 total_liability / retained_profit /
# short_loan / long_loan,_get_multi 兼容两套)
balance = _latest_row(stock_fin.get("Balance"))
tot_shrhldr_eqy = _to_float(_get_multi(balance, ["tot_shrhldr_eqy_excl_min_int", "total_sheet_owner_equities"]))
total_liability = _to_float(_get_multi(balance, ["total_liability", "tot_liab"]))
total_sheet_owner_equities = _to_float(_get_multi(balance, ["total_sheet_owner_equities", "tot_shrhldr_eqy_excl_min_int", "total_equity"]))
retained_profit = _to_float(_get_multi(balance, ["retained_profit", "undistributed_profit"]))
cash_equivalents = _to_float(_get_multi(balance, ["cash_equivalents", "monetary_funds"]))
short_loan = _to_float(_get_multi(balance, ["short_loan", "shortterm_loan"])) or 0.0
long_loan = _to_float(_get_multi(balance, ["long_loan", "long_term_loans"])) or 0.0
bonds_payable = _to_float(_get(balance, "bonds_payable")) or 0.0
total_assets = _to_float(_get_multi(balance, ["tot_assets", "total_assets"]))
interest_bearing_debt = short_loan + long_loan + bonds_payable
# Income(miniQMT 实证 trading hours 常下载超时,空表时用 PershareIndex EPS 反推)
income = _latest_row(stock_fin.get("Income"))
net_profit = _to_float(_get_multi(income, ["net_profit_excl_min_int", "n_income", "net_profit_incl_min_int"]))
revenue = _to_float(_get_multi(income, ["operating_revenue", "revenue", "total_revenue", "operating revenue"]))
oper_profit = _to_float(_get_multi(income, ["oper_profit", "operating_profit"]))
inc_tax = _to_float(_get_multi(income, ["inc_tax", "income_tax"]))
profit_before_tax = _to_float(_get_multi(income, ["profit_before_tax"]))
# CashFlow(同 Income,trading hours 常空)
cashflow = _latest_row(stock_fin.get("CashFlow"))
net_oper_cash_flow = _to_float(_get_multi(cashflow, ["n_cashflow_act", "net_operate_cash_flow", "net_cash_flow_oper"]))
# PershareIndex(实测字段名 du_return_on_equity/s_fa_eps_basic/sales_gross_profit 等)
psh = _latest_row(stock_fin.get("PershareIndex"))
roe = _to_float(_get_multi(psh, ["roe", "du_return_on_equity", "equity_roe"]))
roa = _to_float(_get_multi(psh, ["roa", "return_on_assets", "total_roe"]))
eps = _to_float(_get_multi(psh, ["eps", "s_fa_eps_basic", "s_fa_eps_diluted"]))
gross_profit_margin = _to_float(_get_multi(psh, ["gross_profit_margin", "sales_gross_profit", "gross_profit"]))
net_profit_margin = _to_float(_get_multi(psh, ["net_profit_margin", "du_profit_rate", "net_profit"]))
inc_revenue_yoy = _to_float(_get_multi(psh, ["inc_revenue_year_on_year", "inc_revenue_rate", "inc_revenue"]))
inc_operation_profit_yoy = _to_float(_get_multi(psh, [
"inc_operation_profit_year_on_year", "inc_net_profit_rate", "inc_net_profit",
]))
# 营业总收入同比(annual 口径优先,回退 rate)
inc_total_revenue_yoy = _to_float(_get_multi(psh, [
"inc_total_revenue_year_on_year", "inc_total_revenue_annual", "inc_revenue_rate",
]))
actual_tax_rate = _to_float(_get(psh, "actual_tax_rate"))
# Income 空表兜底:用 EPS × total_capital 得单季净利润(calc_pe 内部 ×4 近似 TTM)
# 注意:不能在这里 ×4,calc_pe 已经 ×4,重复 ×4 会致 PE 偏小 4 倍
if (net_profit is None) and eps is not None and total_capital and total_capital > 0:
net_profit = eps * total_capital
logger.debug("%s Income 空,用 EPS×股本 近似单季 net_profit=%s", jq_code, net_profit)
# ROA 兜底:PershareIndex 没有 roa 字段,用 ROE × (归母权益/总资产) 自算
if (roa is None) and roe is not None and tot_shrhldr_eqy and total_assets and total_assets > 0:
# ROE 是百分数(如 10.57),保持百分数口径
roa = roe * (tot_shrhldr_eqy / total_assets)
# 估值(自算,close None 时全置 NaN)
if close is not None and total_capital and total_capital > 0:
# 聚宽口径:市值亿元
row["market_cap"] = factors.valuation.to_yi(close * total_capital)
row["circulating_market_cap"] = factors.valuation.to_yi(
close * (circulating_capital or total_capital)
)
if net_profit is not None:
row["pe_ratio"] = factors.valuation.calc_pe(close, net_profit, total_capital)
else:
row["pe_ratio"] = float("nan")
if tot_shrhldr_eqy:
row["pb_ratio"] = factors.valuation.calc_pb(close, tot_shrhldr_eqy, total_capital)
else:
row["pb_ratio"] = float("nan")
if revenue is not None:
row["ps_ratio"] = factors.valuation.calc_ps(close, revenue, total_capital)
else:
row["ps_ratio"] = float("nan")
if net_oper_cash_flow is not None:
row["pcf_ratio"] = factors.valuation.calc_pcf(close, net_oper_cash_flow, total_capital)
else:
row["pcf_ratio"] = float("nan")
else:
row["market_cap"] = float("nan")
row["circulating_market_cap"] = float("nan")
row["pe_ratio"] = float("nan")
row["pb_ratio"] = float("nan")
row["ps_ratio"] = float("nan")
row["pcf_ratio"] = float("nan")
# indicator
# 实证 miniQMT PershareIndex 返回的是百分数(茅台 ROE=10.57 表示 10.57%),
# 聚宽 valuation/indicator 是小数(0.1057)。策略层阈值 roe>0.15 按聚宽口径,
# 这里统一归一到小数,×0.01。NaN 透传。
row["roe"] = _pct_to_decimal(roe)
row["roa"] = _pct_to_decimal(roa)
row["eps"] = _or_nan(eps)
row["gross_profit_margin"] = _pct_to_decimal(gross_profit_margin)
row["net_profit_margin"] = _pct_to_decimal(net_profit_margin)
row["inc_revenue_year_on_year"] = _pct_to_decimal(inc_revenue_yoy)
row["inc_operation_profit_year_on_year"] = _pct_to_decimal(inc_operation_profit_yoy)
row["inc_total_revenue_year_on_year"] = _pct_to_decimal(inc_total_revenue_yoy)
# balance(策略层会算 total_liability/total_sheet_owner_equities 比率)
row["total_liability"] = _or_nan(total_liability)
row["total_sheet_owner_equities"] = _or_nan(total_sheet_owner_equities)
row["retained_profit"] = _or_nan(retained_profit)
# ROIC 自算
if oper_profit is not None and tot_shrhldr_eqy and interest_bearing_debt is not None and cash_equivalents is not None:
row["roic"] = factors.roic.calc_roic(
oper_profit, actual_tax_rate, tot_shrhldr_eqy,
interest_bearing_debt, cash_equivalents,
inc_tax=inc_tax, profit_before_tax=profit_before_tax,
)
else:
row["roic"] = float("nan")
# 留原始字段给策略做更多自算
row["_net_profit"] = _or_nan(net_profit)
row["_revenue"] = _or_nan(revenue)
row["_oper_profit"] = _or_nan(oper_profit)
row["_total_capital"] = total_capital
row["_close"] = close if close is not None else float("nan")
return row
# ------------------------ close 行情 ------------------------
def _fetch_close(self, qmt_stocks: List[str], date_str: Optional[str]) -> Dict[str, float]:
"""取 ``date`` 当日(或最近一日)收盘价。失败返空 dict。
实证 miniQMT ``get_market_data_ex`` 的 ``end_time`` 必须 YYYYMMDD(带 dash 报
"结束时间错误"),这里归一。
"""
xt = self._ensure_xtdata()
try:
# get_market_data_ex([], stock_list, period='1d', end_time=date, count=1)
end = _to_yyyymmdd(date_str) or ""
result = xt.get_market_data_ex(
[], qmt_stocks, period="1d", start_time="", end_time=end, count=1
)
except Exception as exc:
logger.debug("get_market_data_ex 失败: %s", exc)
return {}
if not result:
return {}
out: Dict[str, float] = {}
for code, df in result.items():
if df is None or len(df) == 0:
continue
try:
close = float(df["close"].iloc[-1])
out[code] = close
except Exception:
continue
return out
# ------------------------ jq query 解析(最小子集) ------------------------
def _resolve_stocks_from_query(
self, query_object: Any, date: Optional[Union[str, datetime]]
) -> pd.DataFrame:
"""解析 jq-style query 的 stocks 列表 + filter/order_by/limit。
支持:
- ``query(valuation, indicator).filter(valuation.code.in_(...), indicator.roe > 0.15)``
- ``...order_by(valuation.market_cap.asc()/.desc())``
- ``...limit(N)``
复杂 filter(OR / 跨表 join / 自定义函数)抛 NotImplementedError。
"""
# 解析 stocks:从 filter 的 code.in_(...) 提取
stocks = _extract_stocks_from_query(query_object)
if not stocks:
logger.warning("无法从 query 提取 stocks(可能用了不支持的 filter),返空表")
return pd.DataFrame(columns=list(JQ_COLUMN_ALIASES.values()))
df = self.get_fundamentals_df(stocks, date)
# 应用 filter(简单比较/范围)
df = _apply_query_filters(df, query_object)
# 应用 order_by
order_by = _extract_order_by(query_object)
if order_by:
df = _apply_order_by(df, order_by)
# 应用 limit
limit = _extract_limit(query_object)
if isinstance(limit, int) and limit > 0:
df = df.head(limit)
return df
# ======================== jq query ORM 解析辅助 ========================
# bullet-trade 的 query 是轻量 ORM(filter/order_by/limit 返回 self)。
# 我们不依赖它的具体类型,鸭子取属性即可。
def _extract_stocks_from_query(query_object: Any) -> List[str]:
"""从 query 的 code.in_(...) 子句提取股票池。
bullet-trade 的 query 对象上 filter 条件可能存在 ``_filters`` / ``_wheres`` 等内部字段,
我们一律用鸭子反射 + 字符串模式提取 in_(...)。
"""
text = _stringify_query(query_object)
if not text:
return []
import re
# 匹配 in_('600519.XSHG', '000001.XSHE', ...)
matches = re.findall(r"in_\(\s*\[([^\]]*)\]\s*\)", text)
if not matches:
matches = re.findall(r"in_\(([^)]+)\)", text)
if not matches:
return []
found: List[str] = []
for chunk in matches:
for code in re.findall(r"['\"]([0-9A-Za-z]+\.[A-Z]+)['\"]", chunk):
found.append(code)
return _dedup(found)
def _stringify_query(query_object: Any) -> str:
"""把 query 对象转字符串,各种失败容错。"""
try:
return str(query_object)
except Exception:
return ""
def _apply_query_filters(df: pd.DataFrame, query_object: Any) -> pd.DataFrame:
"""从 query 提取 filter 条件,逐条应用到 df。
支持:col == v / > v / < v / >= v / <= v / between(a,b)。
不支持 OR / 跨表 / 自定义函数 → NotImplementedError(标注 v2)。
"""
text = _stringify_query(query_object)
if not text:
return df
import re
# 简单比较: col > 0.15 / col < 30 / col == 1 / col >= 0 / col <= 0
# 字段名带点(valuation.market_cap / indicator.roe),取点后那段
patterns = [
(r"(\w+\.)?(\w+)\s*(>=|<=|==|!=|>|<)\s*([0-9eE.+-]+)", "compare"),
(r"(\w+\.)?(\w+)\.between\(\s*([0-9eE.+-]+)\s*,\s*([0-9eE.+-]+)\s*\)", "between"),
]
for pat, kind in patterns:
for m in re.finditer(pat, text):
col = m.group(2)
if col not in df.columns:
continue
if kind == "compare":
op, val = m.group(3), float(m.group(4))
df = _apply_compare(df, col, op, val)
else:
lo, hi = float(m.group(3)), float(m.group(4))
df = df[(df[col] >= lo) & (df[col] <= hi)]
return df
def _apply_compare(df: pd.DataFrame, col: str, op: str, val: float) -> pd.DataFrame:
if op == ">":
return df[df[col] > val]
if op == "<":
return df[df[col] < val]
if op == ">=":
return df[df[col] >= val]
if op == "<=":
return df[df[col] <= val]
if op == "==":
return df[df[col] == val]
if op == "!=":
return df[df[col] != val]
return df
def _extract_order_by(query_object: Any) -> List[tuple]:
"""提取 order_by 子句 → [(col, 'asc'|'desc'), ...]。
用字符串解析,匹配 valuation.market_cap.asc() / .desc()。
"""
text = _stringify_query(query_object)
if not text:
return []
import re
out = []
for m in re.finditer(r"(\w+\.)?(\w+)\.(asc|desc)\(\)", text):
out.append((m.group(2), m.group(3)))
return out
def _apply_order_by(df: pd.DataFrame, order_by: List[tuple]) -> pd.DataFrame:
if not order_by:
return df
for col, direction in reversed(order_by):
if col not in df.columns:
continue
ascending = direction != "desc"
df = df.sort_values(col, ascending=ascending, na_position="last")
return df
def _extract_limit(query_object: Any) -> Optional[int]:
text = _stringify_query(query_object)
if not text:
return None
import re
m = re.search(r"\.limit\(\s*(\d+)\s*\)", text)
return int(m.group(1)) if m else None
# ======================== 工具 ========================
def _latest_row(table: Any) -> Optional[pd.Series]:
"""取财务表 DataFrame 的最新一行(按报告期 date 倒序)。
xtquant 返回的 PershareIndex/Balance 等是 DataFrame,index 通常为报告期。
"""
if table is None:
return None
if isinstance(table, pd.DataFrame):
if len(table) == 0:
return None
# 尝试按 index(报告期)降序取最新
try:
return table.sort_index(ascending=False).iloc[0]
except Exception:
return table.iloc[-1]
if isinstance(table, dict):
return pd.Series(table)
return None
def _get(series_or_dict: Any, key: str) -> Any:
"""从 Series/dict 取 key,容错 key 不存在/大小写差异。"""
if series_or_dict is None:
return None
if isinstance(series_or_dict, pd.Series):
if key in series_or_dict:
return series_or_dict[key]
# case-insensitive fallback
lower_map = {k.lower(): k for k in series_or_dict.index}
if key.lower() in lower_map:
return series_or_dict[lower_map[key.lower()]]
return None
if isinstance(series_or_dict, dict):
if key in series_or_dict:
return series_or_dict[key]
for k, v in series_or_dict.items():
if k.lower() == key.lower():
return v
return None
return None
def _get_multi(series_or_dict: Any, keys: List[str]) -> Any:
"""按 keys 顺序尝试取,第一个非 None(且非 NaN)的值返回。
用于字段 alias 兼容(如 roe/du_return_on_equity/equity_roe 三套名字)。
"""
for k in keys:
v = _get(series_or_dict, k)
if v is None:
continue
# NaN 透传到下一个 key
try:
fv = float(v)
if np.isnan(fv):
continue
return v
except (TypeError, ValueError):
return v
return None
def _to_yyyymmdd(value: Optional[str]) -> Optional[str]:
"""YYYY-MM-DD → YYYYMMDD(miniQMT get_market_data_ex 要求)。已 YYYYMMDD 直接返。"""
if value is None:
return None
s = str(value).strip()
if not s:
return None
if "-" in s:
return s.replace("-", "")[:8]
return s[:8]
def _to_float(value: Any) -> Optional[float]:
"""Any → float,None/NaN/异常 → None。"""
if value is None:
return None
try:
out = float(value)
if np.isnan(out):
return None
return out
except (TypeError, ValueError):
return None
def _or_nan(value: Optional[float]) -> float:
if value is None:
return float("nan")
return float(value)
def _pct_to_decimal(value: Optional[float]) -> float:
"""百分数 → 小数(10.57 → 0.1057)对齐聚宽 indicator 口径。
NaN/None 透传。绝对值 < 1 时认为已经是小数,不转换(防御)。
"""
if value is None:
return float("nan")
try:
v = float(value)
except (TypeError, ValueError):
return float("nan")
if np.isnan(v):
return float("nan")
if abs(v) < 1:
return v
return v / 100.0
def _to_date_str(value: Optional[Union[str, datetime]]) -> Optional[str]:
if value is None:
return None
if isinstance(value, str):
return value[:10]
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d")
try:
return str(value)[:10]
except Exception:
return None
def _dedup(seq: List[str]) -> List[str]:
seen = set()
out = []
for x in seq:
if x not in seen:
seen.add(x)
out.append(x)
return out
__all__ = ["SanguoMiniQmtProvider", "JQ_COLUMN_ALIASES"]
+191
View File
@@ -0,0 +1,191 @@
"""全天候策略回测入口。
用法(VPS Windows / miniQMT 已连):
set DEFAULT_DATA_PROVIDER=miniqmt
python -m sanguo_portfolio.runner_backtest \\
--start 2020-01-01 --end 2024-12-31 --cash 1000000
Mac 没装 xtquant,这里仅作为入口脚本(测试用 mock,实际跑 rsync 到 VPS)。
"""
from __future__ import annotations
# ENV GUARD 必须早于任何 bullet_trade import
import os
os.environ.setdefault("DEFAULT_DATA_PROVIDER", "miniqmt")
import argparse
import logging
from typing import Any, Dict
logger = logging.getLogger(__name__)
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="sanguo_portfolio 全天候回测")
p.add_argument("--start", default="2020-01-01", help="回测开始日期 YYYY-MM-DD")
p.add_argument("--end", default="2024-12-31", help="回测结束日期 YYYY-MM-DD")
p.add_argument("--cash", type=float, default=1_000_000.0, help="初始资金(元)")
p.add_argument("--benchmark", default="000300.XSHG", help="基准代码")
p.add_argument("--frequency", default="day", help="回测频率 day/minute")
p.add_argument(
"--provider-config", default="{}",
help="provider 配置 JSON 字符串,如 '{\"data_dir\":\"D:/xtdata\"}'",
)
p.add_argument(
"--result-file", default="docs/portfolio_backtest_result.md",
help="结果输出文件(.md)",
)
return p.parse_args()
def build_provider(config_str: str) -> Any:
"""构造 SanguoMiniQmtProvider。"""
import json
from .providers import SanguoMiniQmtProvider
cfg: Dict[str, Any] = {}
if config_str and config_str != "{}":
try:
cfg = json.loads(config_str)
except Exception as exc:
logger.warning("provider-config 解析失败,用默认: %s", exc)
cfg.setdefault("mode", "backtest")
return SanguoMiniQmtProvider(cfg)
def build_broker_facade(engine: Any) -> Any:
"""把 BacktestEngine 的聚宽风格 API 包成 BrokerFacade。"""
from .strategies.all_weather import BrokerFacade
# bullet_trade 的 BacktestEngine 把 set_benchmark/run_daily 等顶层函数注入到策略
# 模块 globals 里。这里把 engine 持有的对应函数转发给 BrokerFacade。
def _order_target_value(code: str, value: float):
try:
return engine.api.order_target_value(code, value)
except Exception:
try:
return engine.order_target_value(code, value)
except Exception as exc:
logger.warning("order_target_value 失败 %s=%s: %s", code, value, exc)
return None
def _order_value(code: str, value: float):
try:
return engine.api.order_value(code, value)
except Exception:
try:
return engine.order_value(code, value)
except Exception as exc:
logger.warning("order_value 失败 %s=%s: %s", code, value, exc)
return None
return BrokerFacade(
order_target_value=_order_target_value,
order_value=_order_value,
)
def run_backtest(args: argparse.Namespace) -> Dict[str, Any]:
"""跑回测,返回结果 dict。
BulletTrade 的 BacktestEngine 接受 strategy_file 或 initialize 等函数。
我们把 AllWeatherStrategy 包成 initialize 函数:initialize 闭包挂 run_daily 等。
"""
from bullet_trade.core import BacktestEngine # type: ignore
from bullet_trade.data.api import set_data_provider # type: ignore
from .strategies import AllWeatherStrategy
provider = build_provider(args.provider_config)
set_data_provider(provider)
# 占位策略:initialize 里把 self(strategy)挂到聚宽风格定时器
holder: Dict[str, Any] = {}
def initialize(context):
strategy = AllWeatherStrategy(provider=provider)
holder["strategy"] = strategy
# bullet-trade 的 run_daily/run_monthly 接受全局函数;把 method 暴露为模块级
# 这里偷个懒:用 functools.partial 注册到 engine 的 scheduler
import functools
# bullet-trade 顶层 run_daily 等可调用,context._scheduler 暴露
try:
from bullet_trade.core import run_daily, run_monthly # type: ignore
run_daily(strategy.prepare_stock_list, "9:05")
run_monthly(strategy.monthly_adjustment, 1, "9:30")
run_daily(strategy.stop_loss, "14:00")
except Exception as exc:
logger.warning("注册定时任务失败(回测可能不触达): %s", exc)
strategy.initialize(context)
holder["broker"] = build_broker_facade_inner(strategy, context)
strategy.broker = holder["broker"]
def build_broker_facade_inner(strategy: AllWeatherStrategy, context: Any):
from .strategies.all_weather import BrokerFacade
# 在回测内,聚宽风格 order_target_value 来自 bullet_trade 顶层
from bullet_trade.core.api import ( # type: ignore
order_target_value as bt_otv,
order_value as bt_ov,
)
return BrokerFacade(
order_target_value=lambda c, v: bt_otv(c, v),
order_value=lambda c, v: bt_ov(c, v),
)
engine = BacktestEngine(
initialize=initialize,
start_date=args.start,
end_date=args.end,
frequency=args.frequency,
initial_cash=args.cash,
benchmark=args.benchmark,
)
result = engine.run()
# 输出结果摘要到 markdown
_write_result_md(result, args.result_file, args)
return result
def _write_result_md(result: Dict[str, Any], path: str, args: argparse.Namespace) -> None:
"""把回测关键指标写成 markdown(给 docs/portfolio_backtest_result.md)。"""
try:
summary = result.get("summary", {}) if isinstance(result, dict) else {}
lines = [
"# sanguo_portfolio 全天候回测结果",
"",
f"- 区间: {args.start} ~ {args.end}",
f"- 初始资金: {args.cash:,.0f}",
f"- 基准: {args.benchmark}",
"",
"## 关键指标",
"",
"| 指标 | 值 |",
"|---|---|",
]
for k in (
"total_returns", "annual_returns", "benchmark_returns",
"alpha", "beta", "sharpe", "sortino", "max_drawdown",
"win_rate", "turnover",
):
if k in summary:
lines.append(f"| {k} | {summary[k]} |")
content = "\n".join(lines)
with open(path, "w") as f:
f.write(content + "\n")
logger.info("回测结果写入 %s", path)
except Exception as exc:
logger.warning("写结果文件失败: %s", exc)
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
args = parse_args()
run_backtest(args)
if __name__ == "__main__":
main()
+110
View File
@@ -0,0 +1,110 @@
"""全天候策略实盘入口(VPS Windows / miniQMT 直连)。
**实盘就绪,但需在交易日+miniQMT 连接下首次跑**。
用法:
set DEFAULT_DATA_PROVIDER=miniqmt
set MINIQMT_MARKET=SH
python -m sanguo_portfolio.runner_live
不在 Mac 跑(Mac 无 xtquant/miniQMT 客户端)。
"""
from __future__ import annotations
# ENV GUARD 必须早于任何 bullet_trade import
import os
os.environ.setdefault("DEFAULT_DATA_PROVIDER", "miniqmt")
import logging
from typing import Any, Dict
logger = logging.getLogger(__name__)
def build_provider(provider_config: Dict[str, Any] | None = None) -> Any:
"""构造 live 模式的 SanguoMiniQmtProvider。"""
from .providers import SanguoMiniQmtProvider
cfg = dict(provider_config or {})
cfg.setdefault("mode", "live")
cfg.setdefault("auto_download", True)
return SanguoMiniQmtProvider(cfg)
def build_broker_facade() -> Any:
"""实盘 BrokerFacade:委托 bullet_trade 顶层聚宽风格 API + QmtBroker。"""
from .strategies.all_weather import BrokerFacade
from bullet_trade.core.api import ( # type: ignore
order_target_value as bt_otv,
order_value as bt_ov,
set_benchmark, set_option, set_slippage, set_order_cost,
run_daily, run_monthly,
)
return BrokerFacade(
order_target_value=lambda c, v: bt_otv(c, v),
order_value=lambda c, v: bt_ov(c, v),
set_benchmark=set_benchmark,
set_option=set_option,
set_slippage=set_slippage,
set_order_cost=set_order_cost,
run_daily=run_daily,
run_monthly=run_monthly,
)
def run_live(provider_config: Dict[str, Any] | None = None) -> None:
"""启动 LiveEngine + AllWeatherStrategy。
LiveEngine 负责驱动 scheduler(context.current_dt 推进)和下单路由。
我们负责把 provider/broker 注入 AllWeatherStrategy。
"""
from bullet_trade.core.live_engine import LiveEngine # type: ignore
from bullet_trade.data.api import set_data_provider # type: ignore
from bullet_trade.broker.qmt import QmtBroker # type: ignore
from .strategies import AllWeatherConfig, AllWeatherStrategy
provider = build_provider(provider_config)
set_data_provider(provider)
broker_facade = build_broker_facade()
strategy = AllWeatherStrategy(
provider=provider,
broker=broker_facade,
config=AllWeatherConfig(),
)
def initialize(context):
strategy.initialize(context)
# QmtBroker 需要 account_id + data_path(miniQMT userdata_mini)。
# 从 env 读(与 sanguo_live 约定一致),缺 account 拒绝启动避免误下单。
account = os.environ.get("SANGUO_QMT_ACCOUNT", "")
mini_path = (os.environ.get("SANGUO_QMT_PATH")
or r"C:\国金QMT交易端模拟\userdata_mini")
if not account:
raise RuntimeError(
"缺 SANGUO_QMT_ACCOUNT(miniQMT 交易账号),实盘无法启动。"
"设 set SANGUO_QMT_ACCOUNT=66639661 后重试。"
)
broker = QmtBroker(account_id=account, data_path=mini_path)
logger.info("QmtBroker 装配 account=%s data_path=%s", account, mini_path)
# 小资金 1e6 起步,等交易日观察(9:05 prepare / 月初 9:30 monthly / 14:00 stop_loss)
engine = LiveEngine(
initialize=initialize,
initial_cash=1_000_000.0,
broker=broker,
)
logger.info("AllWeather live engine 启动,等交易日触发 monthly_adjustment")
engine.run()
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
run_live()
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
"""sanguo_portfolio 策略层。"""
from .all_weather import AllWeatherConfig, AllWeatherStrategy, BrokerFacade
__all__ = ["AllWeatherStrategy", "AllWeatherConfig", "BrokerFacade"]
+550
View File
@@ -0,0 +1,550 @@
"""聚宽"全天候轮动"策略(post48819)翻译到 BulletTrade 框架。
聚宽源码完整保留在原仓库 transcript,这里做**结构等价**翻译:
- ``initialize`` → ``AllWeatherStrategy.initialize``
- ``prepare_stock_list`` → ``AllWeatherStrategy.prepare_stock_list``
- ``stop_loss`` → ``AllWeatherStrategy.stop_loss``
- ``monthly_adjustment`` → ``AllWeatherStrategy.monthly_adjustment``
- ``SMALL/BIG/ROIC_BIG/BM`` → ``AllWeatherStrategy.small/big/roic_big/bm``
- ``filter_*`` → ``sanguo_portfolio.filters``
策略层不直接 import bullet-trade 顶层 API(避免 Mac dev 环境装不全崩),通过两个注入点接入:
1. ``self.provider`` → SanguoMiniQmtProvider(继承 MiniQMTProvider,补 fundamentals)
2. ``self.broker_facade`` → 注入聚宽风格的全局函数(set_benchmark/run_daily/order_target_value/...)
可选,回测/实盘 runner 注入;测试用 Mock
数据/下单**全部**走这两个注入点,不在策略层硬调 bullet_trade。
"""
from __future__ import annotations
import datetime
import logging
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Sequence
import numpy as np
import pandas as pd
from .. import factors, filters
logger = logging.getLogger(__name__)
# ------------------------ 注入 facade ------------------------
@dataclass
class BrokerFacade:
"""聚宽风格全局函数的注入容器。
runner 在实盘/回测中实现这些方法(委托 bullet_trade 顶层 API);
测试用 MagicMock 即可。所有字段都是 Callable,默认空实现(避免 None 调用崩)。
"""
order_target_value: Callable[[str, float], Any] = lambda code, value: None
order_value: Callable[[str, float], Any] = lambda code, value: None
get_price: Optional[Callable] = None
history: Optional[Callable] = None
get_current_data: Optional[Callable] = None
set_benchmark: Callable[[str], None] = lambda code: None
set_option: Callable[[str, Any], None] = lambda k, v: None
set_slippage: Callable[[Any], None] = lambda x: None
set_order_cost: Callable[[Any, str], None] = lambda cost, type: None
run_daily: Callable[..., None] = lambda *a, **kw: None
run_monthly: Callable[..., None] = lambda *a, **kw: None
# ------------------------ 策略 ------------------------
@dataclass
class AllWeatherConfig:
"""全天候策略参数(聚宽 initialize 的硬编码抽出来便于调参)。"""
stock_num: int = 3 # g.stock_num
foreign_etf: List[str] = field(default_factory=lambda: [
"518880.XSHG",
"513030.XSHG",
"513100.XSHG",
"164824.XSHE",
"159866.XSHE",
])
trend_window: int = 10 # B_ratio / S_ratio 涨幅窗口
trend_threshold: float = 10.0 # "无敌好行情"阈值
stop_loss_pct: float = 0.92 # 持仓 price < avg_cost * 0.92 止损
high_price_threshold_small: float = 10.0 # SMALL 筛低价股阈值
high_price_threshold_big: float = 300.0 # BIG 筛高价股阈值
benchmark: str = "000300.XSHG"
roic_threshold: float = 0.08 # filter_roic 的 ROIC > 阈值
new_stock_days: int = 375
class AllWeatherStrategy:
"""聚宽"全天候轮动"策略(动态选股 + 大小盘轮动 + 海外 ETF 兜底 + 涨停盯盘)。
实例化时不连数据/不下单,所有 IO 走注入的 ``provider`` 和 ``broker``。
runner 负责注入,测试用 mock。
"""
def __init__(
self,
provider: Any,
broker: Optional[BrokerFacade] = None,
config: Optional[AllWeatherConfig] = None,
) -> None:
self.provider = provider
self.broker = broker or BrokerFacade()
self.config = config or AllWeatherConfig()
# 聚宽 g.* 全局变量映射到实例属性
self.hold_list: List[str] = []
self.yesterday_hl_list: List[str] = []
self.no_trading_today_signal: bool = False
# =================== 聚宽 initialize ===================
def initialize(self, context: Any) -> None:
"""聚宽 initialize 等价物:set_benchmark / run_daily / run_monthly / 成本滑点。"""
b = self.broker
b.set_benchmark(self.config.benchmark)
b.set_option("use_real_price", True)
b.set_option("avoid_future_data", True)
try:
from bullet_trade.core import FixedSlippage # type: ignore
b.set_slippage(FixedSlippage(0))
except Exception:
pass
try:
from bullet_trade.core import OrderCost # type: ignore
b.set_order_cost(
OrderCost(
open_tax=0, close_tax=0.001,
open_commission=0.0003, close_commission=0.0003,
close_today_commission=0, min_commission=5,
),
type="stock",
)
except Exception:
pass
# 定时任务
b.run_daily(self.prepare_stock_list, "9:05")
b.run_monthly(self.monthly_adjustment, 1, "9:30")
b.run_daily(self.stop_loss, "14:00")
# =================== prepare_stock_list ===================
def prepare_stock_list(self, context: Any) -> None:
"""刷新 g.hold_list + g.yesterday_HL_list(持仓中昨日涨停的)。"""
positions = _get_positions(context)
self.hold_list = list(positions.keys())
self.yesterday_hl_list = []
if not self.hold_list:
return
previous_date = _previous_date_str(context)
if not previous_date:
return
try:
df = self.provider.get_price(
self.hold_list,
end_date=previous_date,
frequency="daily",
fields=["close", "high_limit"],
count=1,
panel=False,
fill_paused=False,
)
except Exception as exc:
logger.warning("prepare_stock_list get_price 失败: %s", exc)
return
if df is None or len(df) == 0:
return
try:
hit = df[df["close"] == df["high_limit"]]
self.yesterday_hl_list = list(hit.get("code", []))
except Exception as exc:
logger.debug("prepare_stock_list 解析涨停失败: %s", exc)
# =================== stop_loss ===================
def stop_loss(self, context: Any) -> None:
"""昨日涨停打开则卖出;持仓 price < avg_cost*0.92 止损;补仓最多 N 只。"""
positions = _get_positions(context)
num_sold = 0
now_time = _current_dt(context)
# 1) 昨日涨停观察:尾盘不涨停则卖出
for stock in self.yesterday_hl_list:
if stock not in positions:
continue
row = self._intraday_high_low(stock, now_time)
if row is None:
continue
close = row.get("close")
high_limit = row.get("high_limit")
if close is None or high_limit is None:
continue
if close < high_limit:
logger.info("[%s]涨停打开,卖出", stock)
self._close_position(stock)
num_sold += 1
else:
logger.info("[%s]涨停,继续持有", stock)
# 2) 止损 -8%
remaining: List[str] = []
drops: List[float] = []
for stock, pos in positions.items():
price = _pos_price(pos)
avg_cost = _pos_avg_cost(pos)
if price is None or avg_cost is None or avg_cost <= 0:
continue
if price < avg_cost * self.config.stop_loss_pct:
self.broker.order_target_value(stock, 0)
logger.debug("止损 Selling out %s", stock)
num_sold += 1
else:
remaining.append(stock)
drops.append((price - avg_cost) / avg_cost)
# 3) 止损 >=1 次时,从剩余持仓里补跌最多的 N 只加仓(N=stock_num)
if num_sold >= 1 and remaining:
n_pick = min(self.config.stock_num, len(remaining))
# 跌幅最负的 n_pick 只(即"补跌最多")
idx_sorted = np.argsort(drops)[:n_pick]
picked = [remaining[i] for i in idx_sorted]
cash = _available_cash(context) / n_pick
for code in picked:
self.broker.order_value(code, cash)
logger.debug("补跌最多的N支 Order %s", code)
# =================== monthly_adjustment ===================
def monthly_adjustment(self, context: Any) -> None:
"""每月调仓:大小盘轮动 + 选股 + 海外 ETF 兜底 + 调仓下单。"""
cfg = self.config
previous_date = _previous_date_str(context)
current_dt = _current_dt(context)
# 1) 候选池
b_stocks = self._stock_pool("000300.XSHG", previous_date)
s_stocks = self._stock_pool("399101.XSHE", previous_date)
# 2) 取流通市值 top20(大盘)/bottom20(小盘)做趋势信号
blst = self._market_cap_top(b_stocks, previous_date, top=True, n=20)
slst = self._market_cap_top(s_stocks, previous_date, top=False, n=20)
b_mean = self._trend_mean(blst, previous_date, cfg.trend_window)
s_mean = self._trend_mean(slst, previous_date, cfg.trend_window)
logger.info("B_mean=%s S_mean=%s", b_mean, s_mean)
# 3) 轮动决策
if b_mean > cfg.trend_threshold or s_mean > cfg.trend_threshold:
if b_mean > s_mean:
target = self._pick_big_universe(b_stocks, current_dt, previous_date)
else:
target = self.small(s_stocks, current_dt, previous_date)[: cfg.stock_num * 3]
elif b_mean > s_mean and b_mean > 0:
target = self._pick_big_universe(b_stocks, current_dt, previous_date)
elif b_mean < s_mean and s_mean > 0:
target = self.small(s_stocks, current_dt, previous_date)[: cfg.stock_num * 3]
else:
logger.info("开外盘(海外 ETF)")
target = list(cfg.foreign_etf)
# 4) 过滤涨停/跌停/停牌
target = filters.filter_limitup_stock(
target, self.provider, positions=list(_get_positions(context).keys())
)
target = filters.filter_limitdown_stock(
target, self.provider, positions=list(_get_positions(context).keys())
)
target = filters.filter_paused_stock(target, self.provider)
target = _dedup(target)
# 5) 调仓:先清掉不在 target 的(除昨日涨停)
positions = _get_positions(context)
for stock in list(positions.keys()):
if stock in target or stock in self.yesterday_hl_list:
continue
self._close_position(stock)
# 6) 买入 target 里的新股
position_count = len(positions)
target_num = len(target)
if target_num > position_count:
cash = _available_cash(context) / (target_num - position_count)
for stock in target:
if stock in positions:
continue
if self._open_position(stock, cash):
positions = _get_positions(context) # 刷新
if len(positions) >= target_num:
break
logger.info("monthly_adjustment 结束: target=%s", target)
# =================== 选股函数 SMALL/BIG/ROIC_BIG/BM ===================
def small(self, choice: List[str], current_dt: Any, previous_date: str) -> List[str]:
"""SMALL: roe>0.15 & roa>0.10,按 market_cap asc。"""
cfg = self.config
df = self.provider.get_fundamentals_df(choice, date=previous_date)
if df.empty:
return []
filtered = df[(df["roe"] > 0.15) & (df["roa"] > 0.10)]
filtered = filtered.sort_values("market_cap", ascending=True, na_position="last")
return list(filtered.index)
def big(self, choice: List[str], current_dt: Any, previous_date: str) -> List[str]:
"""BIG: 多因子筛选,按 market_cap desc,取前 stock_num。"""
cfg = self.config
df = self.provider.get_fundamentals_df(choice, date=previous_date)
if df.empty:
return []
mask = (
df["pe_ratio"].between(0, 30)
& df["ps_ratio"].between(0, 8)
& (df["pcf_ratio"] < 10)
& (df["eps"] > 0.3)
& (df["roe"] > 0.1)
& (df["net_profit_margin"] > 0.1)
& (df["gross_profit_margin"] > 0.3)
& (df["inc_revenue_year_on_year"] > 0.25)
)
filtered = df[mask].sort_values("market_cap", ascending=False, na_position="last")
return list(filtered.index)[: cfg.stock_num]
def roic_big(self, choice: List[str], current_dt: Any, previous_date: str) -> List[str]:
"""ROIC_BIG: 多因子筛选 + ROIC 过滤,按 retained_profit desc 取前 stock_num。"""
cfg = self.config
df = self.provider.get_fundamentals_df(choice, date=previous_date)
if df.empty:
return []
# 安全做负债率比率(避免除零)
liability_ratio = df["total_liability"] / df["total_sheet_owner_equities"].clip(lower=1e-9)
mask = (
(df["market_cap"] > 300)
& df["pe_ratio"].between(0, 50)
& (df["eps"] > 0.12)
& (df["roa"] > 0.15)
& (liability_ratio < 0.5)
& (df["inc_total_revenue_year_on_year"] > 0.3)
& (df["inc_revenue_year_on_year"] > 0.2)
& (df["retained_profit"] > 0)
)
filtered = df[mask]
# ROIC 过滤(自算列,NaN 默认排除)
filtered = filtered[filtered["roic"].fillna(-1) > cfg.roic_threshold]
filtered = filtered.sort_values("retained_profit", ascending=False, na_position="last")
return list(filtered.index)[: cfg.stock_num]
def bm(self, choice: List[str], current_dt: Any, previous_date: str) -> List[str]:
"""BM: 中市值价值股,按 market_cap asc 取前 stock_num。"""
cfg = self.config
df = self.provider.get_fundamentals_df(choice, date=previous_date)
if df.empty:
return []
mask = (
df["market_cap"].between(100, 900)
& df["pb_ratio"].between(0, 10)
& (df["pcf_ratio"] < 4)
& (df["eps"] > 0.3)
& (df["roe"] > 0.2)
& (df["net_profit_margin"] > 0.1)
& (df["inc_revenue_year_on_year"] > 0.2)
& (df["inc_operation_profit_year_on_year"] > 0.1)
)
filtered = df[mask].sort_values("market_cap", ascending=True, na_position="last")
return list(filtered.index)[: cfg.stock_num]
def _pick_big_universe(
self, b_stocks: List[str], current_dt: Any, previous_date: str
) -> List[str]:
"""大市行情:ROIC_BIG + BIG + BM 去重合并。"""
t1 = self.roic_big(b_stocks, current_dt, previous_date)
t2 = self.big(b_stocks, current_dt, previous_date)
t3 = self.bm(b_stocks, current_dt, previous_date)
return _dedup(t3 + t1 + t2)
# =================== filter_roic ===================
def filter_roic(self, stock_list: List[str], previous_date: str) -> List[str]:
"""聚宽 filter_roic:只保留 ROIC > 0.08。
聚宽原版用 ``get_factor_values(stock, 'roic_ttm')``,这里走我们自算的 roic
(provider 已合并到 df['roic'])。逐只查比 df 慢,但保留原签名便于实证对账。
"""
threshold = self.config.roic_threshold
out: List[str] = []
for stock in stock_list:
df = self.provider.get_fundamentals_df([stock], date=previous_date)
if df.empty:
continue
roic = float(df["roic"].iloc[0]) if "roic" in df.columns else float("nan")
if roic == roic and roic > threshold:
out.append(stock)
return out
# =================== 调仓辅助 ===================
def _close_position(self, code: str) -> bool:
order = self.broker.order_target_value(code, 0)
return order is not None
def _open_position(self, code: str, value: float) -> bool:
order = self.broker.order_target_value(code, value)
if order is None:
return False
return True
# =================== 数据辅助 ===================
def _stock_pool(self, index_symbol: str, previous_date: str) -> List[str]:
"""成分股 + 过滤 ST/科创北交/次新。"""
try:
stocks = self.provider.get_index_stocks(index_symbol, previous_date)
except Exception as exc:
logger.warning("get_index_stocks(%s) 失败: %s", index_symbol, exc)
return []
stocks = filters.filter_kcbj_stock(stocks)
stocks = filters.filter_st_stock(stocks, self.provider)
stocks = filters.filter_new_stock(stocks, self.provider, previous_date, self.config.new_stock_days)
return stocks
def _market_cap_top(
self, stocks: List[str], date: str, *, top: bool, n: int
) -> List[str]:
"""按 circulating_market_cap 取 top/bottom n。"""
if not stocks:
return []
df = self.provider.get_fundamentals_df(stocks, date=date)
if df.empty:
return []
asc = not top
df = df.sort_values("circulating_market_cap", ascending=asc, na_position="last")
return list(df.index)[:n]
def _trend_mean(self, stocks: List[str], end_date: str, n: int) -> float:
"""N 日涨幅(% )的均值。聚宽原版取 close 涨幅,这里同样。"""
if not stocks:
return 0.0
try:
df = self.provider.get_price(
stocks,
end_date=end_date,
frequency="1d",
fields=["close"],
count=n,
panel=False,
)
except Exception as exc:
logger.warning("get_price trend 失败: %s", exc)
return 0.0
if df is None or df.empty:
return 0.0
try:
pivot = df.pivot(index="time", columns="code", values="close")
except Exception:
return 0.0
if len(pivot) < 2:
return 0.0
change = (pivot.iloc[-1] / pivot.iloc[0] - 1) * 100
arr = np.nan_to_num(change.to_numpy())
return float(np.mean(arr))
def _intraday_high_low(self, stock: str, now_time: Any) -> Optional[Dict[str, Any]]:
"""取当日 1m close + high_limit(聚宽 stop_loss 用)。"""
try:
df = self.provider.get_price(
stock,
end_date=now_time,
frequency="1m",
fields=["close", "high_limit"],
skip_paused=False,
fq="pre",
count=1,
panel=False,
fill_paused=True,
)
except Exception as exc:
logger.debug("intraday_high_low 失败 %s: %s", stock, exc)
return None
if df is None or len(df) == 0:
return None
row = df.iloc[0]
return {"close": row.get("close"), "high_limit": row.get("high_limit")}
# ======================== context 适配 ========================
def _get_positions(context: Any) -> Dict[str, Any]:
"""聚宽 context.portfolio.positions,兼容 dict / list / 对象。"""
if context is None:
return {}
portfolio = getattr(context, "portfolio", None) or (context.get("portfolio") if isinstance(context, dict) else None)
if portfolio is None:
return {}
positions = getattr(portfolio, "positions", None)
if positions is None and isinstance(portfolio, dict):
positions = portfolio.get("positions")
if positions is None:
return {}
if isinstance(positions, dict):
return dict(positions)
# list of Position
return {p.security: p for p in positions if hasattr(p, "security")}
def _available_cash(context: Any) -> float:
portfolio = getattr(context, "portfolio", None)
if portfolio is None:
return 0.0
cash = getattr(portfolio, "available_cash", None)
if cash is None:
cash = getattr(portfolio, "cash", None)
return float(cash or 0.0)
def _current_dt(context: Any) -> Any:
return getattr(context, "current_dt", None) or (context.get("current_dt") if isinstance(context, dict) else None)
def _previous_date_str(context: Any) -> Optional[str]:
pd_ = getattr(context, "previous_date", None)
if pd_ is None:
return None
if isinstance(pd_, str):
return pd_[:10]
try:
return pd_.strftime("%Y-%m-%d")
except AttributeError:
try:
return str(pd_)[:10]
except Exception:
return None
def _pos_price(pos: Any) -> Optional[float]:
for attr in ("price", "last_sale_price"):
v = getattr(pos, attr, None)
if v is not None:
return float(v)
if isinstance(pos, dict):
for k in ("price", "last_sale_price"):
if k in pos:
return float(pos[k])
return None
def _pos_avg_cost(pos: Any) -> Optional[float]:
for attr in ("avg_cost", "cost_basis", "price_average"):
v = getattr(pos, attr, None)
if v is not None:
return float(v)
if isinstance(pos, dict):
for k in ("avg_cost", "cost_basis"):
if k in pos:
return float(pos[k])
return None
def _dedup(seq: Sequence[str]) -> List[str]:
seen = set()
out: List[str] = []
for x in seq:
if x not in seen:
seen.add(x)
out.append(x)
return out
__all__ = ["AllWeatherStrategy", "AllWeatherConfig", "BrokerFacade"]
+7
View File
@@ -0,0 +1,7 @@
"""sanguo_portfolio 测试包。
导入即设默认 provider 为 miniqmt(避免 bullet_trade __init__ 拉 jqdatasdk)。
"""
import os as _os
_os.environ.setdefault("DEFAULT_DATA_PROVIDER", "miniqmt")
+263
View File
@@ -0,0 +1,263 @@
"""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)
+365
View File
@@ -0,0 +1,365 @@
"""AllWeatherStrategy 单元测试(mock provider + mock broker)。
策略层只测**逻辑分支正确**(选股 / 轮动决策 / 调仓),不测真实数据。
真实数据回测在 VPS 跑,这里只保证策略翻译等价。
"""
from __future__ import annotations
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
from sanguo_portfolio import AllWeatherConfig, AllWeatherStrategy, BrokerFacade
# ------------------------ 测试 helper:构造策略实例 ------------------------
def make_strategy(
*,
fund_df: pd.DataFrame | None = None,
index_stocks_map: Dict[str, List[str]] | None = None,
price_df_map: Dict[str, pd.DataFrame] | None = None,
) -> AllWeatherStrategy:
"""构造一个 mock provider + mock broker 驱动的策略。
- fund_df: 默认 get_fundamentals_df 返回
- index_stocks_map: get_index_stocks 返回,dict[index] -> List[code]
- price_df_map: get_price 按 (code, fields) 缓存的返回
"""
provider = MagicMock(name="provider")
# 默认 fundamentals:空表,测试里覆盖
if fund_df is None:
fund_df = pd.DataFrame(columns=["code"])
provider.get_fundamentals_df.return_value = fund_df
# get_index_stocks
index_stocks_map = index_stocks_map or {}
def _get_index_stocks(index_symbol, date=None):
return list(index_stocks_map.get(index_symbol, []))
provider.get_index_stocks.side_effect = _get_index_stocks
# get_security_info(filter_st/filter_new 默认放过)
provider.get_security_info.return_value = {
"display_name": "NORMAL",
"name": "600519",
"start_date": datetime(2000, 1, 1),
}
# get_live_current:不停牌不涨跌停
provider.get_live_current.return_value = {
"paused": False, "last_price": 10.0,
"high_limit": 11.0, "low_limit": 9.0,
}
# get_price 按 key 缓存
price_df_map = price_df_map or {}
def _get_price(security, **kwargs):
# 构造 cache key:不严格,按 security+fields+count 取
fields = tuple(kwargs.get("fields") or [])
count = kwargs.get("count", 1)
key = (str(security), fields, count)
return price_df_map.get(key, pd.DataFrame())
provider.get_price.side_effect = _get_price
broker = BrokerFacade()
broker.order_target_value = MagicMock(return_value=MagicMock(filled=100))
broker.order_value = MagicMock(return_value=MagicMock(filled=100))
broker.set_benchmark = MagicMock()
broker.set_option = MagicMock()
broker.run_daily = MagicMock()
broker.run_monthly = MagicMock()
return AllWeatherStrategy(provider=provider, broker=broker)
def make_fund_df(rows: List[Dict[str, Any]]) -> pd.DataFrame:
"""构造 fundamentals DataFrame(带 index = code)。"""
if not rows:
return pd.DataFrame(columns=["code"])
df = pd.DataFrame(rows)
df["code"] = df.get("code", df.index.astype(str))
df = df.set_index("code", drop=False)
return df
# =================== initialize ===================
class TestInitialize:
def test_initialize_registers_scheduled_tasks(self, fake_context):
# Arrange
s = make_strategy()
# Act
s.initialize(fake_context)
# Assert:run_daily / run_monthly 各被调一次(至少)
assert s.broker.run_daily.called
assert s.broker.run_monthly.called
assert s.broker.set_benchmark.called
def test_initialize_sets_benchmark_from_config(self, fake_context):
cfg = AllWeatherConfig(benchmark="000300.XSHG")
s = make_strategy()
s.config = cfg
s.initialize(fake_context)
s.broker.set_benchmark.assert_called_with("000300.XSHG")
# =================== prepare_stock_list ===================
class TestPrepareStockList:
def test_empty_positions_clears_lists(self):
# Arrange
s = make_strategy()
ctx = MagicMock()
ctx.portfolio.positions = {}
ctx.previous_date = "2024-09-30"
# Act
s.prepare_stock_list(ctx)
# Assert
assert s.hold_list == []
assert s.yesterday_hl_list == []
def test_populates_hold_list_from_positions(self):
s = make_strategy()
pos = MagicMock(); pos.security = "600519.XSHG"
ctx = MagicMock()
ctx.portfolio.positions = {"600519.XSHG": pos}
ctx.previous_date = "2024-09-30"
# get_price 返回空(不报错即可)
s.provider.get_price.return_value = pd.DataFrame()
s.prepare_stock_list(ctx)
assert s.hold_list == ["600519.XSHG"]
def test_records_yesterday_limit_up(self):
s = make_strategy()
# 清掉 make_strategy 设置的 side_effect,直接用 return_value
s.provider.get_price.side_effect = None
pos = MagicMock(); pos.security = "600519.XSHG"
ctx = MagicMock()
ctx.portfolio.positions = {"600519.XSHG": pos}
ctx.previous_date = "2024-09-30"
# close == high_limit 视为涨停
s.provider.get_price.return_value = pd.DataFrame({
"code": ["600519.XSHG"],
"close": [10.0],
"high_limit": [10.0],
})
s.prepare_stock_list(ctx)
assert "600519.XSHG" in s.yesterday_hl_list
# =================== stop_loss ===================
class TestStopLoss:
def test_stop_loss_triggers_when_price_drops_8pct(self):
"""avg_cost=100, price=91 (< 100*0.92=92) → 止损。"""
from tests.portfolio.conftest import FakePosition, FakeContext
s = make_strategy()
pos = FakePosition("600519.XSHG", avg_cost=100.0, price=91.0)
ctx = FakeContext(positions={"600519.XSHG": pos})
s.yesterday_hl_list = [] # 跳过昨日涨停分支
s.stop_loss(ctx)
s.broker.order_target_value.assert_called_with("600519.XSHG", 0)
def test_stop_loss_skipped_when_price_above_threshold(self):
from tests.portfolio.conftest import FakePosition, FakeContext
s = make_strategy()
pos = FakePosition("600519.XSHG", avg_cost=100.0, price=95.0) # > 92
ctx = FakeContext(positions={"600519.XSHG": pos})
s.yesterday_hl_list = []
s.stop_loss(ctx)
# 不调 order_target_value(code, 0)
sell_calls = [c for c in s.broker.order_target_value.call_args_list if c.args[1] == 0]
assert sell_calls == []
# =================== monthly_adjustment:轮动决策分支 ===================
class TestMonthlyAdjustmentDecision:
def test_foreign_etf_branch_when_both_trends_negative(self):
"""b_mean < 0 且 s_mean < 0 → 开外盘(海外 ETF)。"""
# Arrange
s = make_strategy(
index_stocks_map={
"000300.XSHG": ["600519.XSHG"],
"399101.XSHE": ["000001.XSHE"],
},
price_df_map={
# trend window = 10, 但 close 都跌
("['600519.XSHG']", ("close",), 10): pd.DataFrame({
"time": pd.to_datetime(["2024-09-20", "2024-09-30"]),
"code": ["600519.XSHG"] * 2,
"close": [15.0, 10.0], # 跌
}),
("['000001.XSHE']", ("close",), 10): pd.DataFrame({
"time": pd.to_datetime(["2024-09-20", "2024-09-30"]),
"code": ["000001.XSHE"] * 2,
"close": [15.0, 10.0],
}),
},
)
# 流通市值 top/bottom 的 fund_df:让 _market_cap_top 仍能跑
s.provider.get_fundamentals_df.return_value = make_fund_df([
{"code": "600519.XSHG", "circulating_market_cap": 20000, "market_cap": 20000},
{"code": "000001.XSHE", "circulating_market_cap": 500, "market_cap": 500},
])
ctx = MagicMock()
ctx.current_dt = datetime(2024, 10, 8, 9, 30)
ctx.previous_date = "2024-09-30"
ctx.portfolio.positions = {}
ctx.portfolio.available_cash = 1_000_000
# Act
s.monthly_adjustment(ctx)
# Assert:海外 ETF 在 order_target_value 入参里
called_codes = [c.args[0] for c in s.broker.order_target_value.call_args_list]
for etf in s.config.foreign_etf:
assert etf in called_codes, f"未触发海外 ETF 下单: {etf}"
def test_big_market_branch_when_b_trend_dominant(self):
"""b_mean > s_mean 且 b_mean > 0 → 开大(选 B_stocks)。"""
s = make_strategy(
index_stocks_map={
"000300.XSHG": ["600519.XSHG"],
"399101.XSHE": ["000001.XSHE"],
},
price_df_map={
("['600519.XSHG']", ("close",), 10): pd.DataFrame({
"time": pd.to_datetime(["2024-09-20", "2024-09-30"]),
"code": ["600519.XSHG"] * 2,
"close": [10.0, 15.0], # 涨 50%
}),
("['000001.XSHE']", ("close",), 10): pd.DataFrame({
"time": pd.to_datetime(["2024-09-20", "2024-09-30"]),
"code": ["000001.XSHE"] * 2,
"close": [10.0, 11.0], # 涨 10%
}),
},
)
# 选股函数返回的 fund_df:让 big 路径选到 1 只
big_fund = make_fund_df([{
"code": "600519.XSHG", "market_cap": 20000,
"circulating_market_cap": 20000,
"pe_ratio": 10.0, "ps_ratio": 2.0, "pcf_ratio": 2.0,
"eps": 1.0, "roe": 0.2, "roa": 0.15,
"net_profit_margin": 0.2, "gross_profit_margin": 0.5,
"inc_revenue_year_on_year": 0.3,
"inc_operation_profit_year_on_year": 0.2,
"inc_total_revenue_year_on_year": 0.4,
"total_liability": 1e9, "total_sheet_owner_equities": 1e10,
"retained_profit": 5e9, "roic": 0.15, "pb_ratio": 2.0,
}])
s.provider.get_fundamentals_df.return_value = big_fund
ctx = MagicMock()
ctx.current_dt = datetime(2024, 10, 8, 9, 30)
ctx.previous_date = "2024-09-30"
ctx.portfolio.positions = {}
ctx.portfolio.available_cash = 1_000_000
s.monthly_adjustment(ctx)
# 600519 应被买入(开大 + 多个选股函数都会选它)
buy_calls = [
c.args[0] for c in s.broker.order_target_value.call_args_list
if c.args[1] != 0
]
assert "600519.XSHG" in buy_calls
# =================== 选股函数直接测试 ===================
class TestStockPickers:
def test_small_filters_by_roe_roa(self):
"""roe>0.15 & roa>0.10 → 仅保留合格股,按 market_cap asc。"""
df = make_fund_df([
{"code": "A.XSHG", "roe": 0.20, "roa": 0.15, "market_cap": 500},
{"code": "B.XSHG", "roe": 0.10, "roa": 0.20, "market_cap": 300}, # roe 不够
{"code": "C.XSHG", "roe": 0.30, "roa": 0.05, "market_cap": 200}, # roa 不够
{"code": "D.XSHG", "roe": 0.25, "roa": 0.12, "market_cap": 100},
])
s = make_strategy()
s.provider.get_fundamentals_df.return_value = df
out = s.small(["A", "B", "C", "D"], current_dt=None, previous_date="2024-09-30")
# A 和 D 合格,D 市值小排前
assert out == ["D.XSHG", "A.XSHG"]
def test_big_applies_full_multi_factor_filter(self):
df = make_fund_df([{
# 全部满足
"code": "PASS.XSHG",
"market_cap": 500,
"pe_ratio": 15.0, "ps_ratio": 3.0, "pcf_ratio": 5.0,
"eps": 1.0, "roe": 0.2, "net_profit_margin": 0.2,
"gross_profit_margin": 0.5, "inc_revenue_year_on_year": 0.3,
}, {
"code": "FAIL.XSHG",
"market_cap": 800,
"pe_ratio": 50.0, # pe 不在 [0,30]
"ps_ratio": 3.0, "pcf_ratio": 5.0,
"eps": 1.0, "roe": 0.2, "net_profit_margin": 0.2,
"gross_profit_margin": 0.5, "inc_revenue_year_on_year": 0.3,
}])
s = make_strategy()
s.provider.get_fundamentals_df.return_value = df
out = s.big(["PASS", "FAIL"], current_dt=None, previous_date="2024-09-30")
assert out == ["PASS.XSHG"]
def test_roic_big_filters_by_roic_threshold(self):
"""ROIC > 0.08 才保留。"""
df = make_fund_df([
{"code": "HIGH.XSHG", "market_cap": 500, "pe_ratio": 20,
"eps": 0.5, "roa": 0.20, "total_liability": 1e8,
"total_sheet_owner_equities": 1e10, "retained_profit": 5e9,
"inc_total_revenue_year_on_year": 0.4,
"inc_revenue_year_on_year": 0.3, "roic": 0.15},
{"code": "LOW.XSHG", "market_cap": 500, "pe_ratio": 20,
"eps": 0.5, "roa": 0.20, "total_liability": 1e8,
"total_sheet_owner_equities": 1e10, "retained_profit": 5e9,
"inc_total_revenue_year_on_year": 0.4,
"inc_revenue_year_on_year": 0.3, "roic": 0.05}, # ROIC 不够
])
s = make_strategy()
s.provider.get_fundamentals_df.return_value = df
out = s.roic_big(["HIGH", "LOW"], current_dt=None, previous_date="2024-09-30")
assert "HIGH.XSHG" in out
assert "LOW.XSHG" not in out
def test_bm_uses_mid_cap_value_filters(self):
df = make_fund_df([{
"code": "GOOD.XSHG",
"market_cap": 500, "pb_ratio": 2.0, "pcf_ratio": 2.0,
"eps": 1.0, "roe": 0.3, "net_profit_margin": 0.2,
"inc_revenue_year_on_year": 0.3,
"inc_operation_profit_year_on_year": 0.2,
}, {
"code": "BIG.XSHG",
"market_cap": 1000, # 不在 [100, 900]
"pb_ratio": 2.0, "pcf_ratio": 2.0,
"eps": 1.0, "roe": 0.3, "net_profit_margin": 0.2,
"inc_revenue_year_on_year": 0.3,
"inc_operation_profit_year_on_year": 0.2,
}])
s = make_strategy()
s.provider.get_fundamentals_df.return_value = df
out = s.bm(["GOOD", "BIG"], current_dt=None, previous_date="2024-09-30")
assert out == ["GOOD.XSHG"]
# =================== filter_roic ===================
class TestFilterRoic:
def test_filters_below_threshold(self):
df = make_fund_df([{"code": "A.XSHG", "roic": 0.15}])
s = make_strategy()
s.provider.get_fundamentals_df.return_value = df
out = s.filter_roic(["A.XSHG", "B.XSHG"], previous_date="2024-09-30")
# 第 2 次调用 fund_df 也是同一个 mock,所以 B 也算 roic=0.15 → 都保留
assert "A.XSHG" in out
def test_empty_input_returns_empty(self):
s = make_strategy()
out = s.filter_roic([], previous_date="2024-09-30")
assert out == []
+291
View File
@@ -0,0 +1,291 @@
"""valuation / roic 因子纯函数测试(AAA 模式)。"""
from __future__ import annotations
import math
import numpy as np
import pandas as pd
import pytest
from sanguo_portfolio import factors
# =================== valuation: calc_market_cap ===================
class TestCalcMarketCap:
def test_scalar_inputs_returns_close_times_capital(self):
# Arrange
close = 10.0
total_capital = 1_000_000_000 # 10 亿股
# Act
out = factors.valuation.calc_market_cap(close, total_capital)
# Assert
assert out == pytest.approx(1.0e10)
def test_series_inputs_broadcast_correctly(self):
# Arrange
close = pd.Series([10.0, 20.0, 30.0])
cap = pd.Series([1e6, 2e6, 3e6])
# Act
out = factors.valuation.calc_market_cap(close, cap)
# Assert
assert list(out) == [1e7, 4e7, 9e7]
def test_zero_capital_returns_zero(self):
# Arrange
# Act
out = factors.valuation.calc_market_cap(10.0, 0.0)
# Assert
assert out == 0.0
# =================== valuation: calc_circulating_market_cap ===================
class TestCalcCirculatingMarketCap:
def test_scalar_inputs_returns_close_times_circulating(self):
out = factors.valuation.calc_circulating_market_cap(5.0, 2e8)
assert out == pytest.approx(1e9)
# =================== valuation: calc_pe ===================
class TestCalcPe:
def test_positive_net_profit_normal_pe(self):
# close=10, cap=1e9, profit=2.5e8 → 单期 PE = 10e9 / (2.5e8 * 4) = 10
out = factors.valuation.calc_pe(10.0, 2.5e8, 1e9)
assert out == pytest.approx(10.0)
def test_negative_net_profit_negative_pe(self):
# 亏损 → 负 PE
out = factors.valuation.calc_pe(10.0, -2.5e8, 1e9)
assert out < 0
def test_zero_net_profit_does_not_inflate_to_inf(self):
# 分母保护:0 → eps(1e-9),不会是 inf
out = factors.valuation.calc_pe(10.0, 0.0, 1e9)
assert math.isfinite(out)
assert out > 1e9 # 极大值
def test_series_inputs(self):
out = factors.valuation.calc_pe(
pd.Series([10.0, 20.0]),
pd.Series([2.5e8, -2.5e8]),
pd.Series([1e9, 1e9]),
)
assert out.iloc[0] == pytest.approx(10.0)
assert out.iloc[1] < 0
# =================== valuation: calc_pb ===================
class TestCalcPb:
def test_positive_equity_normal_pb(self):
# close=10, cap=1e9, equity=5e9 → PB = 1e10/5e9 = 2
out = factors.valuation.calc_pb(10.0, 5e9, 1e9)
assert out == pytest.approx(2.0)
def test_zero_equity_protected(self):
out = factors.valuation.calc_pb(10.0, 0.0, 1e9)
assert math.isfinite(out)
# =================== valuation: calc_ps / calc_pcf ===================
class TestCalcPsPcf:
def test_ps_uses_revenue_times_4(self):
# close=10, cap=1e9, revenue=2.5e8 → PS = 1e10/(2.5e8*4) = 10
out = factors.valuation.calc_ps(10.0, 2.5e8, 1e9)
assert out == pytest.approx(10.0)
def test_pcf_uses_cash_flow_times_4(self):
out = factors.valuation.calc_pcf(10.0, 1e8, 1e9)
# 1e10 / (1e8 * 4) = 25
assert out == pytest.approx(25.0)
# =================== valuation: to_yi ===================
class TestToYi:
def test_yuan_to_yi(self):
assert factors.valuation.to_yi(1e8) == pytest.approx(1.0)
def test_series_to_yi(self):
out = factors.valuation.to_yi(pd.Series([1e8, 2e8]))
assert list(out) == [1.0, 2.0]
# =================== roic: normalize_tax_rate ===================
class TestNormalizeTaxRate:
def test_percent_form_divided_by_100(self):
# 25.0 视为百分数 → 0.25
out = factors.roic.normalize_tax_rate(25.0)
assert out == pytest.approx(0.25)
def test_decimal_form_kept(self):
out = factors.roic.normalize_tax_rate(0.25)
assert out == pytest.approx(0.25)
def test_none_returns_nan(self):
out = factors.roic.normalize_tax_rate(None)
assert math.isnan(out)
def test_nan_passes_through(self):
out = factors.roic.normalize_tax_rate(float("nan"))
assert math.isnan(out)
def test_series_mixed(self):
s = pd.Series([25.0, 0.15, float("nan")])
out = factors.roic.normalize_tax_rate(s)
assert out.iloc[0] == pytest.approx(0.25)
assert out.iloc[1] == pytest.approx(0.15)
assert math.isnan(out.iloc[2])
# =================== roic: fallback_tax_rate ===================
class TestFallbackTaxRate:
def test_normal_inc_tax_and_profit(self):
out = factors.roic.fallback_tax_rate(25.0, 100.0)
assert out == pytest.approx(0.25)
def test_zero_or_negative_profit_returns_zero(self):
out = factors.roic.fallback_tax_rate(25.0, 0.0)
assert out == 0.0
out2 = factors.roic.fallback_tax_rate(25.0, -100.0)
assert out2 == 0.0
def test_none_returns_nan(self):
out = factors.roic.fallback_tax_rate(None, 100.0)
assert math.isnan(out)
# =================== roic: calc_roic ===================
class TestCalcRoic:
def test_high_quality_business_high_roic(self):
# oper=100, tax=25%(decimal), equity=200, debt=0, cash=0
# NOPAT = 100*(1-0.25)=75; IC = 200; ROIC = 75/200 = 0.375
out = factors.roic.calc_roic(
oper_profit=100.0,
actual_tax_rate=0.25,
tot_shrhldr_eqy=200.0,
interest_bearing_debt=0.0,
cash_equivalents=0.0,
)
assert out == pytest.approx(0.375)
def test_percent_form_tax_rate_normalized(self):
# actual_tax_rate=25 (percent form) → 0.25
out = factors.roic.calc_roic(
oper_profit=100.0,
actual_tax_rate=25.0,
tot_shrhldr_eqy=200.0,
interest_bearing_debt=0.0,
cash_equivalents=0.0,
)
assert out == pytest.approx(0.375)
def test_cash_reduces_invested_capital(self):
# cash=50 → IC = 200-50=150; NOPAT=75 → ROIC = 0.5
out = factors.roic.calc_roic(
oper_profit=100.0, actual_tax_rate=0.25,
tot_shrhldr_eqy=200.0, interest_bearing_debt=0.0,
cash_equivalents=50.0,
)
assert out == pytest.approx(0.5)
def test_debt_increases_invested_capital(self):
# debt=100 → IC=300; NOPAT=75 → ROIC = 0.25
out = factors.roic.calc_roic(
oper_profit=100.0, actual_tax_rate=0.25,
tot_shrhldr_eqy=200.0, interest_bearing_debt=100.0,
cash_equivalents=0.0,
)
assert out == pytest.approx(0.25)
def test_non_positive_invested_capital_returns_nan(self):
# IC = 0 - 0 - 0 = 0 → NaN
out = factors.roic.calc_roic(
oper_profit=100.0, actual_tax_rate=0.25,
tot_shrhldr_eqy=0.0, interest_bearing_debt=0.0,
cash_equivalents=0.0,
)
assert math.isnan(out)
def test_negative_invested_capital_returns_nan(self):
# cash > equity + debt → IC < 0
out = factors.roic.calc_roic(
oper_profit=100.0, actual_tax_rate=0.25,
tot_shrhldr_eqy=100.0, interest_bearing_debt=0.0,
cash_equivalents=200.0,
)
assert math.isnan(out)
def test_nan_tax_uses_fallback(self):
# actual_tax_rate=NaN + inc_tax=25, profit=100 → rate=0.25 → ROIC=0.375
out = factors.roic.calc_roic(
oper_profit=100.0, actual_tax_rate=float("nan"),
tot_shrhldr_eqy=200.0, interest_bearing_debt=0.0,
cash_equivalents=0.0,
inc_tax=25.0, profit_before_tax=100.0,
)
assert out == pytest.approx(0.375)
# =================== roic: Series 批量路径(覆盖数组分支) ===================
class TestCalcRoicSeries:
def test_series_input_returns_series(self):
s = pd.Series([100.0, 200.0])
out = factors.roic.calc_roic(
oper_profit=s,
actual_tax_rate=pd.Series([0.25, 0.25]),
tot_shrhldr_eqy=pd.Series([200.0, 400.0]),
interest_bearing_debt=pd.Series([0.0, 0.0]),
cash_equivalents=pd.Series([0.0, 0.0]),
)
assert isinstance(out, pd.Series)
assert out.iloc[0] == pytest.approx(0.375)
assert out.iloc[1] == pytest.approx(0.375)
def test_series_with_zero_invested_capital_returns_nan(self):
# IC=0 的那一行 NaN
out = factors.roic.calc_roic(
oper_profit=pd.Series([100.0, 100.0]),
actual_tax_rate=pd.Series([0.25, 0.25]),
tot_shrhldr_eqy=pd.Series([200.0, 0.0]),
interest_bearing_debt=pd.Series([0.0, 0.0]),
cash_equivalents=pd.Series([0.0, 0.0]),
)
assert isinstance(out, pd.Series)
assert out.iloc[0] == pytest.approx(0.375)
assert math.isnan(out.iloc[1])
def test_series_normalize_tax_rate_with_mixed(self):
# 25(百分数)+ 0.15(小数) → [0.25, 0.15]
out = factors.roic.normalize_tax_rate(pd.Series([25.0, 0.15]))
assert out.iloc[0] == pytest.approx(0.25)
assert out.iloc[1] == pytest.approx(0.15)
def test_series_fallback_tax_rate_zero_profit_row(self):
# 第 2 行利润总额=0 → 税率=0
out = factors.roic.fallback_tax_rate(
pd.Series([25.0, 25.0]),
pd.Series([100.0, 0.0]),
)
assert out.iloc[0] == pytest.approx(0.25)
assert out.iloc[1] == 0.0
def test_series_nan_tax_uses_fallback_per_row(self):
# 一行正常税率 + 一行 NaN,补 fallback
out = factors.roic.calc_roic(
oper_profit=pd.Series([100.0, 100.0]),
actual_tax_rate=pd.Series([0.25, float("nan")]),
tot_shrhldr_eqy=pd.Series([200.0, 200.0]),
interest_bearing_debt=pd.Series([0.0, 0.0]),
cash_equivalents=pd.Series([0.0, 0.0]),
inc_tax=pd.Series([25.0, 25.0]),
profit_before_tax=pd.Series([100.0, 100.0]),
)
assert out.iloc[0] == pytest.approx(0.375)
assert out.iloc[1] == pytest.approx(0.375)
def test_numpy_array_input(self):
out = factors.roic.normalize_tax_rate(np.array([25.0, 0.15]))
assert out[0] == pytest.approx(0.25)
assert out[1] == pytest.approx(0.15)
def test_fallback_numpy_input(self):
out = factors.roic.fallback_tax_rate(np.array([25.0]), np.array([0.0]))
assert out[0] == 0.0
+216
View File
@@ -0,0 +1,216 @@
"""filters.py 单元测试:ST/停牌/科创北交/次新/涨跌停。"""
from __future__ import annotations
from datetime import datetime, timedelta
import pytest
from sanguo_portfolio import filters
# =================== filter_st_stock ===================
class TestFilterSt:
def test_st_stock_filtered_out(self, mock_provider):
# Arrange: ST 茅台 + 正常平安
mock_provider.get_security_info.side_effect = lambda code, date=None: {
"display_name": "ST茅台" if "600519" in code else "中国平安",
"name": code.split(".")[0],
}
stocks = ["600519.XSHG", "601318.XSHG"]
# Act
out = filters.filter_st_stock(stocks, mock_provider)
# Assert
assert out == ["601318.XSHG"]
def test_asterisk_prefix_filtered(self, mock_provider):
mock_provider.get_security_info.side_effect = lambda code, date=None: {
"display_name": "*ST盐湖", "name": code,
}
out = filters.filter_st_stock(["000792.XSHE"], mock_provider)
assert out == []
def test_delisting_stock_filtered(self, mock_provider):
mock_provider.get_security_info.side_effect = lambda code, date=None: {
"display_name": "退市美都", "name": code,
}
out = filters.filter_st_stock(["600175.XSHG"], mock_provider)
assert out == []
def test_provider_call_failure_keeps_stock(self, mock_provider):
# 取不到名字保留(宁错过不误杀)
mock_provider.get_security_info.side_effect = Exception("network")
out = filters.filter_st_stock(["600519.XSHG"], mock_provider)
assert out == ["600519.XSHG"]
# =================== filter_paused_stock ===================
class TestFilterPaused:
def test_paused_stock_filtered(self, mock_provider):
mock_provider.get_live_current.side_effect = lambda code: {
"paused": True, "last_price": 10.0,
"high_limit": 11.0, "low_limit": 9.0,
}
out = filters.filter_paused_stock(["600519.XSHG"], mock_provider)
assert out == []
def test_trading_stock_kept(self, mock_provider):
mock_provider.get_live_current.side_effect = lambda code: {
"paused": False, "last_price": 10.0,
"high_limit": 11.0, "low_limit": 9.0,
}
out = filters.filter_paused_stock(["600519.XSHG"], mock_provider)
assert out == ["600519.XSHG"]
def test_tick_failure_keeps_stock(self, mock_provider):
mock_provider.get_live_current.side_effect = Exception("no tick")
out = filters.filter_paused_stock(["600519.XSHG"], mock_provider)
assert out == ["600519.XSHG"]
# =================== filter_kcbj_stock ===================
class TestFilterKcbj:
def test_keeps_main_board(self):
out = filters.filter_kcbj_stock([
"600519.XSHG", # 沪市主板
"000001.XSHE", # 深市主板
])
assert out == ["600519.XSHG", "000001.XSHE"]
def test_filters_chinext_300(self):
out = filters.filter_kcbj_stock(["300750.XSHE"]) # 宁德时代 创业板
assert out == []
def test_filters_star_market_688(self):
out = filters.filter_kcbj_stock(["688981.XSHG"]) # 中芯国际 科创板
assert out == []
def test_filters_bse_8(self):
out = filters.filter_kcbj_stock(["832000.XSHG"]) # 北交所
assert out == []
def test_filters_bse_4(self):
out = filters.filter_kcbj_stock(["430047.XSHG"])
assert out == []
def test_mixed_input(self):
out = filters.filter_kcbj_stock([
"600519.XSHG", "300750.XSHE", "000001.XSHE", "688981.XSHG",
])
assert out == ["600519.XSHG", "000001.XSHE"]
# =================== filter_new_stock ===================
class TestFilterNewStock:
def test_recently_listed_filtered(self, mock_provider):
# 上市 100 天,< 375 天 → 过滤
today = datetime(2024, 9, 30)
mock_provider.get_security_info.side_effect = lambda code, date=None: {
"start_date": today - timedelta(days=100),
}
out = filters.filter_new_stock(["600519.XSHG"], mock_provider, today)
assert out == []
def test_old_stock_kept(self, mock_provider):
today = datetime(2024, 9, 30)
mock_provider.get_security_info.side_effect = lambda code, date=None: {
"start_date": today - timedelta(days=1000),
}
out = filters.filter_new_stock(["600519.XSHG"], mock_provider, today)
assert out == ["600519.XSHG"]
def test_custom_days_threshold(self, mock_provider):
today = datetime(2024, 9, 30)
mock_provider.get_security_info.side_effect = lambda code, date=None: {
"start_date": today - timedelta(days=200),
}
# days=100 → 200>100 保留
out = filters.filter_new_stock(["600519.XSHG"], mock_provider, today, days=100)
assert out == ["600519.XSHG"]
# days=375 → 200<375 过滤
out = filters.filter_new_stock(["600519.XSHG"], mock_provider, today, days=375)
assert out == []
def test_missing_start_date_kept(self, mock_provider):
today = datetime(2024, 9, 30)
mock_provider.get_security_info.side_effect = lambda code, date=None: {}
out = filters.filter_new_stock(["600519.XSHG"], mock_provider, today)
assert out == ["600519.XSHG"]
def test_string_today_accepted(self, mock_provider):
today = "2024-09-30"
mock_provider.get_security_info.side_effect = lambda code, date=None: {
"start_date": datetime(2001, 8, 27),
}
out = filters.filter_new_stock(["600519.XSHG"], mock_provider, today)
assert out == ["600519.XSHG"]
# =================== filter_limitup_stock ===================
class TestFilterLimitUp:
def test_hits_limit_filtered_out(self, mock_provider):
mock_provider.get_live_current.side_effect = lambda code: {
"paused": False, "last_price": 11.0,
"high_limit": 11.0, "low_limit": 9.0,
}
out = filters.filter_limitup_stock(["600519.XSHG"], mock_provider)
assert out == []
def test_below_limit_kept(self, mock_provider):
mock_provider.get_live_current.side_effect = lambda code: {
"paused": False, "last_price": 10.0,
"high_limit": 11.0, "low_limit": 9.0,
}
out = filters.filter_limitup_stock(["600519.XSHG"], mock_provider)
assert out == ["600519.XSHG"]
def test_position_held_keeps_even_at_limit(self, mock_provider):
# 持仓中的涨停股不过滤
mock_provider.get_live_current.side_effect = lambda code: {
"paused": False, "last_price": 11.0,
"high_limit": 11.0, "low_limit": 9.0,
}
out = filters.filter_limitup_stock(
["600519.XSHG"], mock_provider, positions=["600519.XSHG"]
)
assert out == ["600519.XSHG"]
def test_last_prices_override_skips_tick(self, mock_provider):
mock_provider.get_live_current.side_effect = lambda code: {
"paused": False, "last_price": 99.0,
"high_limit": 11.0, "low_limit": 9.0,
}
out = filters.filter_limitup_stock(
["600519.XSHG"], mock_provider, last_prices={"600519.XSHG": 10.0}
)
# last_prices=10 < high_limit=11 → 保留
assert out == ["600519.XSHG"]
# =================== filter_limitdown_stock ===================
class TestFilterLimitDown:
def test_hits_limit_down_filtered_out(self, mock_provider):
mock_provider.get_live_current.side_effect = lambda code: {
"paused": False, "last_price": 9.0,
"high_limit": 11.0, "low_limit": 9.0,
}
out = filters.filter_limitdown_stock(["600519.XSHG"], mock_provider)
assert out == []
def test_above_limit_kept(self, mock_provider):
mock_provider.get_live_current.side_effect = lambda code: {
"paused": False, "last_price": 10.0,
"high_limit": 11.0, "low_limit": 9.0,
}
out = filters.filter_limitdown_stock(["600519.XSHG"], mock_provider)
assert out == ["600519.XSHG"]
def test_position_held_keeps_even_at_limit_down(self, mock_provider):
# 跌停要能卖才平 → 持仓不过滤
mock_provider.get_live_current.side_effect = lambda code: {
"paused": False, "last_price": 9.0,
"high_limit": 11.0, "low_limit": 9.0,
}
out = filters.filter_limitdown_stock(
["600519.XSHG"], mock_provider, positions=["600519.XSHG"]
)
assert out == ["600519.XSHG"]
+143
View File
@@ -0,0 +1,143 @@
"""SanguoMiniQmtProvider 单元测试(mock xtquant)。
bullet-trade 装了(0.2.0+),MiniQMTProvider 基类可继承。
xtquant 没 装,通过 mock_xtquant fixture 注入 sys.modules。
"""
from __future__ import annotations
import math
import pandas as pd
import pytest
from sanguo_portfolio import SanguoMiniQmtProvider
pytestmark = pytest.mark.requires_bullet_trade
class TestSanguoMiniQmtProviderInstantiation:
def test_can_instantiate_with_mock_xtquant(self, mock_xtquant):
# Arrange + Act
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
# Assert
assert provider.name == "sanguo_miniqmt"
# 应继承 MiniQMTProvider
from bullet_trade.data.providers.miniqmt import MiniQMTProvider
assert isinstance(provider, MiniQMTProvider)
class TestGetFundamentalsDf:
def test_returns_dataframe_with_required_columns(self, mock_xtquant):
# Arrange
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
stocks = ["600519.XSHG", "601318.XSHG"]
# Act
df = provider.get_fundamentals_df(stocks, date="2024-09-30")
# Assert
assert isinstance(df, pd.DataFrame)
assert len(df) == 2
# 核心列都在
for col in [
"code", "market_cap", "circulating_market_cap",
"pe_ratio", "pb_ratio", "ps_ratio", "pcf_ratio",
"roe", "roa", "eps",
"total_liability", "total_sheet_owner_equities", "retained_profit",
"roic",
]:
assert col in df.columns, f"missing col: {col}"
# index 是 jq-style code
assert "600519.XSHG" in df.index
def test_empty_stocks_returns_empty_df(self, mock_xtquant):
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
df = provider.get_fundamentals_df([], date="2024-09-30")
assert isinstance(df, pd.DataFrame)
assert len(df) == 0
# 空表也要有列定义,方便上层 select
assert "code" in df.columns
def test_market_cap_in_yi_unit(self, mock_xtquant):
"""close × total_capital / 1e8 = 亿元。茅台 1600 × 12.56e8 / 1e8 = 20096 亿。"""
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
mc = float(df.iloc[0]["market_cap"])
# 茅台市值应在 20000 亿左右(允许 close 1600±10)
assert 19000 < mc < 22000, f"market_cap 异常: {mc}"
def test_pe_ratio_finite_for_profitable_stock(self, mock_xtquant):
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
pe = float(df.iloc[0]["pe_ratio"])
assert math.isfinite(pe)
assert pe > 0
def test_roic_computed(self, mock_xtquant):
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
roic = float(df.iloc[0]["roic"])
# 茅台 mock 数据:oper=1.2e10, tax=25%, eqy=2.2e11, debt=0, cash=1.7e11
# NOPAT = 1.2e10 * 0.75 = 9e9
# IC = 2.2e11 + 0 - 1.7e11 = 5e10
# ROIC = 9e9 / 5e10 = 0.18
assert 0.05 < roic < 0.5, f"ROIC 异常: {roic}"
def test_financial_data_failure_returns_empty_df(self, mock_xtquant):
"""xtdata.get_financial_data 抛异常时返空表(不崩)。"""
mock_xtquant["xtdata"].get_financial_data.side_effect = Exception("QMT offline")
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
df = provider.get_fundamentals_df(["600519.XSHG"], date="2024-09-30")
assert df.empty
class TestGetFundamentalsQueryDictMode:
def test_dict_with_stocks_returns_dataframe(self, mock_xtquant):
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
query = {"stocks": ["600519.XSHG", "601318.XSHG"], "date": "2024-09-30"}
df = provider.get_fundamentals(query)
assert len(df) == 2
def test_dict_with_filter_callable_applied(self, mock_xtquant):
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
query = {
"stocks": ["600519.XSHG", "601318.XSHG"],
"date": "2024-09-30",
"filter": lambda d: d["roe"] > 0.3, # 只保留茅台(归一后 roe=0.30)
}
df = provider.get_fundamentals(query)
assert len(df) == 1
assert df.iloc[0]["code"] == "600519.XSHG"
def test_dict_with_order_by_applied(self, mock_xtquant):
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
query = {
"stocks": ["600519.XSHG", "601318.XSHG"],
"date": "2024-09-30",
"order_by": [("market_cap", "desc")],
}
df = provider.get_fundamentals(query)
assert df.iloc[0]["code"] == "600519.XSHG" # 茅台市值 > 平安
def test_dict_with_limit_applied(self, mock_xtquant):
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
query = {
"stocks": ["600519.XSHG", "601318.XSHG"],
"date": "2024-09-30",
"limit": 1,
}
df = provider.get_fundamentals(query)
assert len(df) == 1
class TestSetDataProviderInjection:
def test_set_data_provider_accepts_sanguo_provider(self, mock_xtquant):
"""set_data_provider 注入 SanguoMiniQmtProvider 实例。"""
from bullet_trade.data.api import get_data_provider, set_data_provider
provider = SanguoMiniQmtProvider({"mode": "backtest", "auto_download": False})
# Act
set_data_provider(provider)
# Assert
active = get_data_provider()
assert active is provider
assert active.name == "sanguo_miniqmt"