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:
@@ -0,0 +1,186 @@
|
||||
# sanguo_portfolio 全天候策略 VPS 回测报告
|
||||
|
||||
> 生成日期:2026-07-18
|
||||
> 环境:VPS(49.232.102.198,Windows,Python 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.1(VPS 预装) |
|
||||
| bullet-trade | 0.9.2(jqdatasdk 列为 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-17(15 个月),单次选股快照,
|
||||
等权持仓至期末。
|
||||
|
||||
### 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 全 NaN,roic_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.4(Q1×4 近似 TTM 偏低,实际 ~25),口径差异已记录 |
|
||||
|
||||
## 6. 已修 / 待修清单
|
||||
|
||||
### ✅ 已修(本次提交)
|
||||
1. provider 字段 alias:11 个字段加 `_get_multi()` 多 alias 回退
|
||||
2. Capital 单位 bug:移除 ×10000(miniQMT 实际返回股数)
|
||||
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`(探针脚本,未提交)
|
||||
@@ -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/ 已同步到 VPS(T9 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_profit(vs 聚宽 roic_ttm)
|
||||
- jq query ORM 仅支持 ==/>/</between/in_/order_by/limit 子集
|
||||
- 聚宽数值对账缺基准(用户不续费 jqdata),仅自洽验证
|
||||
@@ -0,0 +1,271 @@
|
||||
# sanguo_portfolio 实施计划
|
||||
|
||||
把聚宽"全天候轮动"策略(post48819)搬到 BulletTrade 框架,数据源 miniQMT(不用 jqdatasdk),回测验证 + 实盘就绪。
|
||||
|
||||
## 背景已确认(实证)
|
||||
- BulletTrade 0.9.2(MIT),`pip install bullet-trade[all]`,聚宽 API 100% 兼容
|
||||
- **融合机制已验证**(Mac 最小依赖实证 8 项全过):`set_data_provider(provider实例)` 公开 API(data/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`(上市日/名称)
|
||||
|
||||
## 环境
|
||||
- 开发:Mac,venv310(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_data(MiniQMTProvider 已实现)。
|
||||
|
||||
### 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 venv310,mock xtquant)
|
||||
- conftest.py 提供 `mock_xtquant` fixture(sys.modules['xtquant.xtdata'] = MagicMock,返回构造的 PershareIndex/Capital DataFrame)
|
||||
- test_factors.py:valuation/roic 纯函数,给定输入断言输出(AAA 模式)
|
||||
- test_filters.py:各 filter 给定 stocks+mock provider 断言过滤结果
|
||||
- test_provider.py:SanguoMiniQmtProvider 实例化(mock xtquant)、get_fundamentals_df 返回 DataFrame 含正确列、set_data_provider 注入生效
|
||||
- test_all_weather.py:mock 数据下,monthly_adjustment 选股逻辑跑通,返回合理 target_list
|
||||
- 覆盖率目标 80%(factors/filters 必须,provider/策略 mock 覆盖核心路径)
|
||||
|
||||
## 不要做
|
||||
- 不连真 miniQMT(Mac 没有),全 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 包装
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""sanguo_portfolio 因子层。
|
||||
|
||||
纯函数,无 bullet-trade / xtquant 依赖,可在任意环境单元测试。
|
||||
"""
|
||||
from . import roic, valuation
|
||||
|
||||
__all__ = ["roic", "valuation"]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,4 @@
|
||||
"""sanguo_portfolio 策略层。"""
|
||||
from .all_weather import AllWeatherConfig, AllWeatherStrategy, BrokerFacade
|
||||
|
||||
__all__ = ["AllWeatherStrategy", "AllWeatherConfig", "BrokerFacade"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""sanguo_portfolio 测试包。
|
||||
|
||||
导入即设默认 provider 为 miniqmt(避免 bullet_trade __init__ 拉 jqdatasdk)。
|
||||
"""
|
||||
import os as _os
|
||||
|
||||
_os.environ.setdefault("DEFAULT_DATA_PROVIDER", "miniqmt")
|
||||
@@ -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)
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user