From a68cf4905ee91dbdb1a337ad346199936f78d677 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Sat, 18 Jul 2026 19:08:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(portfolio):=20sanguo=5Fportfolio=20?= =?UTF-8?q?=E7=BB=84=E5=90=88=E7=AD=96=E7=95=A5=E6=A1=86=E6=9E=B6(BulletTr?= =?UTF-8?q?ade+miniQMT,=E4=B8=8D=E7=94=A8jqdatasdk)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把聚宽"全天候轮动"(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 --- docs/portfolio_backtest_result.md | 186 +++++ docs/portfolio_live_runbook.md | 53 ++ docs/sanguo_portfolio_plan.md | 271 ++++++++ sanguo_portfolio/__init__.py | 32 + sanguo_portfolio/factors/__init__.py | 7 + sanguo_portfolio/factors/roic.py | 124 ++++ sanguo_portfolio/factors/valuation.py | 129 ++++ sanguo_portfolio/filters.py | 226 ++++++ sanguo_portfolio/providers/__init__.py | 4 + .../providers/sanguo_fundamentals.py | 649 ++++++++++++++++++ sanguo_portfolio/runner_backtest.py | 191 ++++++ sanguo_portfolio/runner_live.py | 110 +++ sanguo_portfolio/strategies/__init__.py | 4 + sanguo_portfolio/strategies/all_weather.py | 550 +++++++++++++++ tests/portfolio/__init__.py | 7 + tests/portfolio/conftest.py | 263 +++++++ tests/portfolio/test_all_weather.py | 365 ++++++++++ tests/portfolio/test_factors.py | 291 ++++++++ tests/portfolio/test_filters.py | 216 ++++++ tests/portfolio/test_provider.py | 143 ++++ 20 files changed, 3821 insertions(+) create mode 100644 docs/portfolio_backtest_result.md create mode 100644 docs/portfolio_live_runbook.md create mode 100644 docs/sanguo_portfolio_plan.md create mode 100644 sanguo_portfolio/__init__.py create mode 100644 sanguo_portfolio/factors/__init__.py create mode 100644 sanguo_portfolio/factors/roic.py create mode 100644 sanguo_portfolio/factors/valuation.py create mode 100644 sanguo_portfolio/filters.py create mode 100644 sanguo_portfolio/providers/__init__.py create mode 100644 sanguo_portfolio/providers/sanguo_fundamentals.py create mode 100644 sanguo_portfolio/runner_backtest.py create mode 100644 sanguo_portfolio/runner_live.py create mode 100644 sanguo_portfolio/strategies/__init__.py create mode 100644 sanguo_portfolio/strategies/all_weather.py create mode 100644 tests/portfolio/__init__.py create mode 100644 tests/portfolio/conftest.py create mode 100644 tests/portfolio/test_all_weather.py create mode 100644 tests/portfolio/test_factors.py create mode 100644 tests/portfolio/test_filters.py create mode 100644 tests/portfolio/test_provider.py diff --git a/docs/portfolio_backtest_result.md b/docs/portfolio_backtest_result.md new file mode 100644 index 0000000..50009d8 --- /dev/null +++ b/docs/portfolio_backtest_result.md @@ -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`(探针脚本,未提交) diff --git a/docs/portfolio_live_runbook.md b/docs/portfolio_live_runbook.md new file mode 100644 index 0000000..0343dec --- /dev/null +++ b/docs/portfolio_live_runbook.md @@ -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 仅支持 ==/>/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 列筛选(支持 ==/>/= 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`,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 包装 diff --git a/sanguo_portfolio/__init__.py b/sanguo_portfolio/__init__.py new file mode 100644 index 0000000..ede5f24 --- /dev/null +++ b/sanguo_portfolio/__init__.py @@ -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", +] diff --git a/sanguo_portfolio/factors/__init__.py b/sanguo_portfolio/factors/__init__.py new file mode 100644 index 0000000..7344992 --- /dev/null +++ b/sanguo_portfolio/factors/__init__.py @@ -0,0 +1,7 @@ +"""sanguo_portfolio 因子层。 + +纯函数,无 bullet-trade / xtquant 依赖,可在任意环境单元测试。 +""" +from . import roic, valuation + +__all__ = ["roic", "valuation"] diff --git a/sanguo_portfolio/factors/roic.py b/sanguo_portfolio/factors/roic.py new file mode 100644 index 0000000..8057720 --- /dev/null +++ b/sanguo_portfolio/factors/roic.py @@ -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"] diff --git a/sanguo_portfolio/factors/valuation.py b/sanguo_portfolio/factors/valuation.py new file mode 100644 index 0000000..dee644b --- /dev/null +++ b/sanguo_portfolio/factors/valuation.py @@ -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, 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", +] diff --git a/sanguo_portfolio/filters.py b/sanguo_portfolio/filters.py new file mode 100644 index 0000000..d8815d7 --- /dev/null +++ b/sanguo_portfolio/filters.py @@ -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", +] diff --git a/sanguo_portfolio/providers/__init__.py b/sanguo_portfolio/providers/__init__.py new file mode 100644 index 0000000..3bd66c3 --- /dev/null +++ b/sanguo_portfolio/providers/__init__.py @@ -0,0 +1,4 @@ +"""sanguo_portfolio 数据 provider 层。""" +from .sanguo_fundamentals import SanguoMiniQmtProvider + +__all__ = ["SanguoMiniQmtProvider"] diff --git a/sanguo_portfolio/providers/sanguo_fundamentals.py b/sanguo_portfolio/providers/sanguo_fundamentals.py new file mode 100644 index 0000000..7e87b62 --- /dev/null +++ b/sanguo_portfolio/providers/sanguo_fundamentals.py @@ -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(==/>/ 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"] diff --git a/sanguo_portfolio/runner_backtest.py b/sanguo_portfolio/runner_backtest.py new file mode 100644 index 0000000..9d1ac9d --- /dev/null +++ b/sanguo_portfolio/runner_backtest.py @@ -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() diff --git a/sanguo_portfolio/runner_live.py b/sanguo_portfolio/runner_live.py new file mode 100644 index 0000000..5fc62a7 --- /dev/null +++ b/sanguo_portfolio/runner_live.py @@ -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() diff --git a/sanguo_portfolio/strategies/__init__.py b/sanguo_portfolio/strategies/__init__.py new file mode 100644 index 0000000..0f17d93 --- /dev/null +++ b/sanguo_portfolio/strategies/__init__.py @@ -0,0 +1,4 @@ +"""sanguo_portfolio 策略层。""" +from .all_weather import AllWeatherConfig, AllWeatherStrategy, BrokerFacade + +__all__ = ["AllWeatherStrategy", "AllWeatherConfig", "BrokerFacade"] diff --git a/sanguo_portfolio/strategies/all_weather.py b/sanguo_portfolio/strategies/all_weather.py new file mode 100644 index 0000000..6a2d51d --- /dev/null +++ b/sanguo_portfolio/strategies/all_weather.py @@ -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"] diff --git a/tests/portfolio/__init__.py b/tests/portfolio/__init__.py new file mode 100644 index 0000000..f8c7f95 --- /dev/null +++ b/tests/portfolio/__init__.py @@ -0,0 +1,7 @@ +"""sanguo_portfolio 测试包。 + +导入即设默认 provider 为 miniqmt(避免 bullet_trade __init__ 拉 jqdatasdk)。 +""" +import os as _os + +_os.environ.setdefault("DEFAULT_DATA_PROVIDER", "miniqmt") diff --git a/tests/portfolio/conftest.py b/tests/portfolio/conftest.py new file mode 100644 index 0000000..187e922 --- /dev/null +++ b/tests/portfolio/conftest.py @@ -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) diff --git a/tests/portfolio/test_all_weather.py b/tests/portfolio/test_all_weather.py new file mode 100644 index 0000000..4d6f89b --- /dev/null +++ b/tests/portfolio/test_all_weather.py @@ -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 == [] diff --git a/tests/portfolio/test_factors.py b/tests/portfolio/test_factors.py new file mode 100644 index 0000000..4380c3e --- /dev/null +++ b/tests/portfolio/test_factors.py @@ -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 diff --git a/tests/portfolio/test_filters.py b/tests/portfolio/test_filters.py new file mode 100644 index 0000000..131d2aa --- /dev/null +++ b/tests/portfolio/test_filters.py @@ -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"] diff --git a/tests/portfolio/test_provider.py b/tests/portfolio/test_provider.py new file mode 100644 index 0000000..0867c17 --- /dev/null +++ b/tests/portfolio/test_provider.py @@ -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"