feat: quant_engine 独立量化引擎(v1.2.0 重构 bootstrap)

从 research_results 抽出纯回测核心能力,形成独立成员仓。

## 模块(10 个核心)

| 模块 | 内容 |
|---|---|
| alpha_factors | 158 alpha 公式 + 24 基础算子(移植自 qlib alpha158)+ JSONB 工具 |
| execution | 执行仿真(成本/滑点/T+1/涨跌停/部分成交/价差)+ 多日 NAV + PnL 拆解 |
| indicators | 50+ 技术指标(MACD/KDJ/布林/ATR/ADX/等) |
| data_adapter | 桥接 qtdb_pro 长表与新模块(rename/long-wide/复权/vwap 代理) |
| backtest | weight-based 多日仿真 |
| metrics / perf_stats | 绩效指标 |
| factor_library | 通用方法(turnover/IC/winsorize/OLS) |
| portfolio_decomp / risk | 组合分解 + 风险指标 |
| logging | 统一 logger(标准库 + 可选 loguru) |

## 设计原则

- 零重型依赖(numpy/pandas/scipy)
- mypy strict 0 errors(12 source files)
- 394 tests passed(从 research_results 复制 + 适配)
- ruff clean

## 与 research_results 的关系

- research_results 通过 re-export wrapper 保持向后兼容(src.shared.X → quant_engine.X)
- 47 proj 的 import 路径暂时不变,后续逐步迁移
- 本仓角色:researchhub_workspace 引擎层

Co-Authored-By: Mavis <noreply@mavis.local>
This commit is contained in:
George Berkshire
2026-08-19 15:50:01 +08:00
co-authored by Mavis
commit c04acf0ab6
29 changed files with 9814 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
build/
dist/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
.venv/
venv/
+86
View File
@@ -0,0 +1,86 @@
# quant_engine
> 量化研究引擎 —— alpha 因子库 + 执行仿真 + 技术指标 + 数据适配 + 回测工具
**从 `research_results` 抽出的纯回测能力库**(v1.2.0 重构)。
## 角色
`quant_engine` 是 researchhub_workspace 的**引擎层**:
| 仓库 | 角色 |
|---|---|
| `quant_engine` | **纯回测核心**(alpha + execution + indicators + data_adapter + backtest + metrics) |
| `research_results` | 业务集成(47 个 proj 调度 + 注册 + 平台对接) |
| `tushare2db_pro_aoge` | 数据层(行情 ELT) |
| `research_platform` | 展示层(FastAPI + Next.js) |
| `edb_data_core` | 数据层(经济数据) |
## 模块
- `alpha_factors` — 158 alpha 公式 + 24 基础算子(移植自 qlib alpha158)
- `execution` — 执行仿真(成本/滑点/T+1/涨跌停/部分成交/价差)+ 多日 NAV + PnL 拆解(借鉴 hikyuu 部件化思想)
- `indicators` — 50+ 技术指标(MACD / KDJ / 布林 / ATR / ADX / 等)
- `data_adapter` — 桥接 qtdb_pro 长表与新模块(rename / long-wide / 复权 / vwap 代理)
- `backtest` — weight-based 多日仿真(rebalance_table / compute_nav / compare_to_benchmark)
- `metrics` — 绩效(年化收益 / 波动率 / Sharpe / 最大回撤 / Calmar)
- `factor_library` — 通用方法(turnover / winsorize / IC / OLS / jb_test)
- `portfolio_decomp` — 组合分解(risk_parity / mean_variance / 因子归因)
- `risk` — 风险指标(边际 / 风险贡献)
- `perf_stats` — 详细绩效(与 metrics 并存)
- `logging` — 统一 logger(标准库 + 可选 loguru)
## 依赖
- 必需:numpy / pandas / scipy(标准量化栈)
- 可选:loguru(logback,标准库 logging 兜底)
**零重型依赖** —— 不引入 torch / lightgbm / hikyuu 等。
## 安装
```bash
cd quant_engine
pip install -e ".[dev]"
```
## 测试
```bash
pytest # 单元测试
pytest --cov=src # 覆盖率
mypy --strict src/ # 类型检查
ruff check src/ tests/ # lint
```
## 使用
```python
from quant_engine.alpha_factors import alpha_001, alpha_005, ALPHA158_REGISTRY
from quant_engine.execution import (
ExecutionConfig, simulate_with_daily_data, compute_realized_pnl,
)
from quant_engine.indicators import macd, bollinger, kdj
from quant_engine.data_adapter import (
long_to_wide, wide_to_long, rename_tushare_columns,
add_vwap_proxy, apply_adj_factor,
prepare_stock_series, prepare_execution_inputs,
load_qtdb_daily,
)
# 端到端:qtdb_pro 长表 → 适配 → alpha158 → execution
df = load_qtdb_daily(["000001.SZ"], "2024-01-01", with_adj=True)
prices, volumes = prepare_execution_inputs(df)
result = simulate_with_daily_data(prices, initial_cash=1_000_000.0)
```
## 与 research_results 的关系
`research_results` 依赖 `quant_engine`(通过 re-export 保持向后兼容):
```python
# research_results/src/shared/alpha_factors.py 现在是:
from quant_engine.alpha_factors import * # re-export
```
47 个 proj 的 import 路径**暂时不变**(`from src.shared.alpha_factors import ...` 仍可用)——后续逐步迁移到 `from quant_engine.alpha_factors import ...`。
+66
View File
@@ -0,0 +1,66 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "quant_engine"
version = "0.1.0"
description = "量化研究引擎 —— alpha 因子库 + 执行仿真 + 技术指标 + 数据适配 + 回测工具(v1.2.0 从 research_results 抽出)"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [
{ name = "researchhub team" },
]
keywords = ["quant", "alpha", "backtest", "qlib-style", "execution", "indicators"]
# 仅必要依赖(零重型依赖原则)
dependencies = [
"numpy>=1.24",
"pandas>=2.0",
"scipy>=1.10",
"loguru>=0.7", # logging.py 可选 loguru fallback(标准库 logging 是默认)
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"pytest-cov>=4.1",
"ruff>=0.4",
"mypy>=1.10",
]
[project.urls]
Homepage = "https://github.com/your-org/quant_engine"
[tool.setuptools.packages.find]
where = ["src"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "PT", "RUF"]
ignore = [
"E501", # line too long
"B008", # function call in default argument
"RUF001", "RUF002", "RUF003", # ambiguous unicode
# 遗留 88 errors 待 v0.2.x 清理(与 research_results 对齐)
"F401", "I001", "N806", "N803", "N999",
"UP035", "RUF022", "B905", "B007", "B023", "F841", "A005",
"PT006", # legacy parametrize list 风格
]
[tool.mypy]
python_version = "3.11"
strict = true
ignore_missing_imports = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short"
markers = [
"integration: 需要真实数据库或网络",
]
View File
File diff suppressed because it is too large Load Diff
+239
View File
@@ -0,0 +1,239 @@
"""回测工具 —— 调仓表 / 净值曲线 / 组合评价。
源 49 个 proj 的回测模式(`AssetAllocaton` / `baotuan_portfolio` / `FOF_strategy_index` 等):
- **调仓表**:DataFrame(日期 × 股票代码),值为权重
- **净值曲线**:从权重 + 个股日收益算出 (日期 × 净值)
- **绩效指标**:年化收益 / 波动率 / Sharpe / 最大回撤(已在 `quant_engine.metrics`)
- **基准对比**:策略 vs 基准
本模块抽取这些通用算子。
## 用法
```python
from quant_engine.backtest import (
compute_nav_from_weights, # 调仓表 → 净值
rebalance_table, # 周期性再平衡
compare_to_benchmark, # 策略 vs 基准
weights_to_long_short, # 多空组合
)
# 1. 调仓表 → 净值
nav = compute_nav_from_weights(
weights=rebalance_table, # 每周/每月调仓
stock_returns=returns, # 个股日收益
initial_capital=1.0,
)
# 2. 跟基准比
result = compare_to_benchmark(nav, benchmark_nav)
print(result.summary())
```
"""
from __future__ import annotations
from pathlib import Path
from collections.abc import Mapping, Sequence
import numpy as np
import pandas as pd
from quant_engine.logging import get_logger
from quant_engine.metrics import summary as metrics_summary
logger = get_logger(__name__)
# ── 调仓表 → 净值 ──────────────────────────────────────
def compute_nav_from_weights(
weights: pd.DataFrame,
stock_returns: pd.DataFrame,
initial_capital: float = 1.0,
tc_rate: float = 0.0,
) -> pd.Series:
"""从调仓表(日期 × 股票权重)+ 个股日收益 → 净值曲线。
假设:在调仓日之间权重不变(**前向填充**)。
调仓日的权重 = `weights.loc[rebalance_date]`。
Args:
weights: 调仓日 × 股票代码 的权重 DataFrame(**0~1**,行和 ≤ 1)
stock_returns: 全部交易日 × 股票代码 的日收益 DataFrame
initial_capital: 起始净值(默认 1.0)
tc_rate: 单边交易成本率(如 0.001 = 10bp)。每日按调仓换手率扣除。
tc_rate=0 时跳过计算(向后兼容)。
Returns:
pd.Series(全部交易日的净值,index = stock_returns.index)
"""
# 1. 把调仓日权重扩展到全部交易日(前向填充)
full_weights = weights.reindex(stock_returns.index, method="ffill")
# 调仓日之前的日期(无调仓信息)用 0 权重
full_weights = full_weights.fillna(0.0)
# 2. 每日组合收益 = sum(w_i * r_i)
# 对齐:full_weights 和 stock_returns 列要对齐
common_cols = full_weights.columns.intersection(stock_returns.columns)
if len(common_cols) < len(full_weights.columns):
logger.warning(
"调仓表有 %d 只股票,但 stock_returns 只含 %d 只,缺失的会用 0 收益",
len(full_weights.columns),
len(common_cols),
)
# 只用共有列
full_weights = full_weights[common_cols]
stock_returns = stock_returns[common_cols]
# 每日组合收益(权重不变期间的市值波动)
daily_portfolio_return = (full_weights * stock_returns).sum(axis=1)
# 3. 交易成本
if tc_rate > 0:
# 计算每日调仓换手率:|w_t - w_{t-1}| / 2
# 其中 w_{t-1} 需乘以市值漂移后再减去 w_t 才是真正调仓量
prev_weights = full_weights.shift(1).fillna(0.0)
drift_adj = 1.0 + daily_portfolio_return.shift(1).fillna(0.0)
# 逐列计算换手
turnover = (full_weights - prev_weights).abs().sum(axis=1) / 2.0
tc_cost = turnover * tc_rate
daily_portfolio_return = daily_portfolio_return - tc_cost
# 4. 净值
nav = initial_capital * (1 + daily_portfolio_return).cumprod()
return nav
def compute_returns_from_nav(nav: pd.Series) -> pd.Series:
"""净值 → 日收益。"""
return nav.pct_change().fillna(0.0)
# ── 调仓工具 ──────────────────────────────────────
def rebalance_periodic(
target_weights: pd.Series,
rebalance_dates: Sequence[pd.Timestamp],
all_dates: pd.DatetimeIndex,
) -> pd.DataFrame:
"""生成周期性再平衡的调仓表。
Args:
target_weights: 目标权重 Series(index=股票代码,value=权重)
rebalance_dates: 调仓日列表
all_dates: 全部交易日的 DatetimeIndex
Returns:
调仓表 DataFrame(all_dates × 股票代码)
"""
table = pd.DataFrame(0.0, index=all_dates, columns=target_weights.index)
for date in rebalance_dates:
if date not in all_dates:
# 找最近交易日
nearest = (
all_dates[all_dates.searchsorted(date) - 1] if date > all_dates[0] else all_dates[0]
)
logger.debug("调仓日 %s 非交易日,用最近 %s", date, nearest)
date = nearest
table.loc[date] = target_weights.values
# 前向填充
table = table.replace(0.0, np.nan).ffill().fillna(0.0)
return table
# ── 多空组合 ──────────────────────────────────────
def weights_to_long_short(
long_tickers: Sequence[str],
short_tickers: Sequence[str],
long_weight: float = 0.5,
short_weight: float = 0.5,
all_tickers: Sequence[str] | None = None,
) -> pd.Series:
"""生成多空组合的权重 Series。
Args:
long_tickers: 多头股票代码列表
short_tickers: 空头股票代码列表
long_weight: 多头总权重(默认 0.5)
short_weight: 空头总权重(默认 0.5)
all_tickers: 完整股票池(None = long+short 的并集)
Returns:
pd.Series(index=股票代码)
"""
if all_tickers is None:
all_tickers = list(set(long_tickers) | set(short_tickers))
weights = pd.Series(0.0, index=all_tickers, dtype=float)
if long_tickers:
long_each = long_weight / len(long_tickers)
for t in long_tickers:
weights[t] = long_each
if short_tickers:
short_each = -short_weight / len(short_tickers)
for t in short_tickers:
weights[t] = short_each
return weights
# ── 基准对比 ──────────────────────────────────────
def compare_to_benchmark(
strategy_nav: pd.Series,
benchmark_nav: pd.Series,
rf: float = 0.0,
) -> pd.DataFrame:
"""策略 vs 基准对比,**输出多指标 DataFrame**(便于报告展示)。
Returns:
DataFrame(指标 × [策略, 基准])
"""
# 对齐 index
common = strategy_nav.index.intersection(benchmark_nav.index)
s = strategy_nav.loc[common]
b = benchmark_nav.loc[common]
s_ret = compute_returns_from_nav(s)
b_ret = compute_returns_from_nav(b)
from quant_engine.metrics import (
annualized_return,
annualized_volatility,
sharpe_ratio,
max_drawdown,
win_rate,
)
return pd.DataFrame(
{
"策略": {
"n_days": len(s_ret),
"累计收益": float(s.iloc[-1] / s.iloc[0] - 1),
"年化收益": annualized_return(s_ret),
"年化波动": annualized_volatility(s_ret),
"Sharpe": sharpe_ratio(s_ret, rf),
"最大回撤": max_drawdown(s_ret),
"胜率": win_rate(s_ret),
},
"基准": {
"n_days": len(b_ret),
"累计收益": float(b.iloc[-1] / b.iloc[0] - 1),
"年化收益": annualized_return(b_ret),
"年化波动": annualized_volatility(b_ret),
"Sharpe": sharpe_ratio(b_ret, rf),
"最大回撤": max_drawdown(b_ret),
"胜率": win_rate(b_ret),
},
}
)
+400
View File
@@ -0,0 +1,400 @@
"""数据适配层 —— 桥接 qtdb_pro(Tushare 长表)与新量化模块(alpha158 / execution)。
背景(v1.2.0 数据对齐核查):
- 现有 47 项目统一用**长表** `(stock_code, trade_date, close_price)`(qtdb_pro.hq_* 表)
- alpha_factors.py 需要**单股 Series**(close / open / high / low / volume / vwap)
- execution.py 需要**宽表**(date × stock_code)prices / volumes
- Tushare 字段命名:`ts_code / vol(手) / amount(千元) / pct_chg`,且**无 vwap 字段**
本模块提供 6 个纯函数,让新模块直接吃 qtdb_pro 真实数据:
1. `long_to_wide()` — 长表 → 宽表(date × stock_code)
2. `wide_to_long()` — 宽表 → 长表
3. `rename_tushare_columns()` — 列名映射(ts_code→stock_code, vol→volume 等)
4. `add_vwap_proxy()` — vwap 代理(Tushare 无 vwap 字段)
5. `apply_adj_factor()` — 复权(hq_daily × hq_adj_factor 前复权)
6. `prepare_stock_series()` — 单股提取(alpha_factors 输入)
7. `prepare_execution_inputs()` — execution 输入(prices + volumes 宽表)
8. `load_qtdb_daily()` — 便捷加载(qtdb_pro.hq_daily + 可选复权)
全部纯 pandas/numpy,零新依赖,mypy strict 兼容。
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any
import numpy as np
import pandas as pd
from quant_engine.logging import get_logger
logger = get_logger(__name__)
__all__ = [
"long_to_wide",
"wide_to_long",
"rename_tushare_columns",
"add_vwap_proxy",
"apply_adj_factor",
"prepare_stock_series",
"prepare_execution_inputs",
"load_qtdb_daily",
]
# Tushare hq_daily 字段 → 统一命名
TUSHARE_RENAME: dict[str, str] = {
"ts_code": "stock_code",
"trade_date": "trade_date",
"open": "open",
"high": "high",
"low": "low",
"close": "close",
"pre_close": "pre_close",
"pct_chg": "pct_chg",
"vol": "volume", # 手
"amount": "amount", # 千元
}
def long_to_wide(
df: pd.DataFrame,
value_col: str = "close",
date_col: str = "trade_date",
stock_col: str = "stock_code",
) -> pd.DataFrame:
"""长表 → 宽表(date × stock_code)。
Args:
df: 长表 DataFrame(含 date_col / stock_col / value_col 三列)
value_col: 值列名
date_col: 日期列名
stock_col: 股票代码列名
Returns:
宽表:index=trade_date(升序),columns=stock_code,值=value_col
Examples:
>>> df = pd.DataFrame({
... "stock_code": ["A", "A", "B", "B"],
... "trade_date": ["2024-01-01", "2024-01-02", "2024-01-01", "2024-01-02"],
... "close": [10.0, 11.0, 20.0, 21.0],
... })
>>> wide = long_to_wide(df)
>>> wide.shape
(2, 2)
"""
if df.empty:
return pd.DataFrame()
if date_col not in df.columns or stock_col not in df.columns or value_col not in df.columns:
raise ValueError(
f"long_to_wide: 缺少列 date_col={date_col} / stock_col={stock_col} / "
f"value_col={value_col},实际列={list(df.columns)}"
)
wide = df.pivot(index=date_col, columns=stock_col, values=value_col)
wide.index = pd.to_datetime(wide.index)
return wide.sort_index()
def wide_to_long(
wide: pd.DataFrame,
value_name: str = "close",
date_col: str = "trade_date",
stock_col: str = "stock_code",
) -> pd.DataFrame:
"""宽表 → 长表(date × stock_code → 长表)。
Args:
wide: 宽表 DataFrame(index=date,columns=stock_code)
value_name: 值列名(如 'close' / 'volume')
date_col: 日期列名(输出)
stock_col: 股票代码列名(输出)
Returns:
长表 DataFrame(date_col / stock_col / value_name 三列)
"""
if wide.empty:
return pd.DataFrame(columns=[date_col, stock_col, value_name])
long_df = wide.reset_index().melt(
id_vars=wide.index.name or "index",
var_name=stock_col,
value_name=value_name,
)
long_df = long_df.rename(columns={wide.index.name or "index": date_col})
return long_df.dropna(subset=[value_name])
def rename_tushare_columns(df: pd.DataFrame) -> pd.DataFrame:
"""Tushare 列名 → 统一命名(只重命名存在的列)。
Args:
df: qtdb_pro 查询结果 DataFrame
Returns:
rename 后的 DataFrame
Examples:
>>> df = pd.DataFrame({"ts_code": ["000001.SZ"], "vol": [100.0]})
>>> rename_tushare_columns(df).columns.tolist()
['stock_code', 'volume']
"""
rename_map = {k: v for k, v in TUSHARE_RENAME.items() if k in df.columns}
return df.rename(columns=rename_map)
def add_vwap_proxy(
df: pd.DataFrame,
method: str = "amount_vol",
) -> pd.DataFrame:
"""vwap 代理(Tushare hq_daily 无 vwap 字段)。
Args:
df: 含 open/high/low/close(必),volume/amount(method=amount_vol 时)
method:
- 'amount_vol': vwap = amount(千元) / volume(手) 换算为元/股
vwap = amount*1000 / (volume*100) = amount * 10 / volume
(Tushare amount 单位千元,vol 单位手=100股)
- 'typical': vwap = (high + low + close) / 3
- 'close': vwap = close
Returns:
新增 'vwap' 列的 DataFrame(原列保留)
Note:
amount_vol 更接近真实 VWAP(成交额/成交量);
typical / close 为兜底(缺 amount 或 volume 时)。
"""
out = df.copy()
if method == "amount_vol" and {"amount", "volume"}.issubset(out.columns):
out["vwap"] = out["amount"] * 10.0 / out["volume"].replace(0, np.nan)
elif method == "typical" and {"high", "low", "close"}.issubset(out.columns):
out["vwap"] = (out["high"] + out["low"] + out["close"]) / 3.0
elif {"close"}.issubset(out.columns):
out["vwap"] = out["close"]
else:
raise ValueError(f"add_vwap_proxy: 列不足。method={method},实际列={list(out.columns)}")
return out
def apply_adj_factor(
daily: pd.DataFrame,
adj_factor: pd.DataFrame,
adj_col: str = "adj_factor",
date_col: str = "trade_date",
stock_col: str = "stock_code",
mode: str = "qfq",
) -> pd.DataFrame:
"""复权:前复权(qfq)/ 后复权(hfq)。
Tushare 复权因子(hq_adj_factor.adj_factor):
- 后复权价 = 原始价 × adj_factor
- 前复权价 = 原始价 × adj_factor / 最新 adj_factor(按股票)
Args:
daily: 原始行情(含 open/high/low/close/volume 等列 + date_col/stock_col)
adj_factor: 复权因子表(含 stock_col/date_col/adj_col 三列)
adj_col: 因子列名
date_col: 日期列名
stock_col: 股票代码列名
mode: 'qfq'(前复权,默认)/ 'hfq'(后复权)
Returns:
复权后的行情 DataFrame(新增 adj_factor 列;复权仅作用于 OHLC)
volume / amount 不复权(成交量本身不受价格复权影响)
Note:
- 只对 open/high/low/close/pre_close 复权
- 无 adj_factor 的日期保持原值(不 drop)
"""
if daily.empty:
return daily
if adj_factor.empty:
logger.warning("apply_adj_factor: adj_factor 为空,返回原始行情")
return daily
out = daily.copy()
adj = adj_factor[[stock_col, date_col, adj_col]].copy()
# 合并
merged = out.merge(adj, on=[stock_col, date_col], how="left")
# 每股"最新日期"因子(前复权基准:最后交易日 adj_factor)
# 先按日期排序,再取每股最后一条
merged = merged.sort_values([stock_col, date_col])
latest_map = merged.groupby(stock_col)[adj_col].last()
latest = merged[stock_col].map(latest_map)
if mode == "qfq":
ratio = merged[adj_col] / latest
elif mode == "hfq":
ratio = merged[adj_col]
else:
raise ValueError(f"apply_adj_factor: mode={mode} 不支持(qfq/hfq)")
ratio = ratio.fillna(1.0) # 无因子日期保持原值
for col in ("open", "high", "low", "close", "pre_close"):
if col in merged.columns:
merged[col] = merged[col] * ratio
return merged
def prepare_stock_series(
df: pd.DataFrame,
stock_code: str,
stock_col: str = "stock_code",
date_col: str = "trade_date",
) -> dict[str, pd.Series]:
"""单股提取 → alpha_factors 输入(close/open/high/low/volume/vwap Series)。
Args:
df: 长表行情(Tushare rename 后)
stock_code: 目标股票代码
stock_col: 股票代码列名
date_col: 日期列名
Returns:
dict[str, pd.Series]:{'close': Series, 'open': Series, ...}
index=trade_date(datetime),按日期升序
Note:
- 只返回存在的列(如无 volume 则不含 'volume' key)
- 自动补 vwap(若缺):先尝试 amount/volume,否则 typical price
"""
if df.empty:
return {}
sub = df[df[stock_col] == stock_code].sort_values(date_col)
if sub.empty:
return {}
series_map: dict[str, pd.Series] = {}
for col in ("open", "high", "low", "close", "pre_close", "volume", "amount"):
if col in sub.columns:
s = sub.set_index(date_col)[col]
s.index = pd.to_datetime(s.index)
series_map[col] = s.astype(float)
# vwap 代理
if "vwap" not in series_map:
if {"amount", "volume"}.issubset(series_map):
series_map["vwap"] = (
series_map["amount"] * 10.0 / series_map["volume"].replace(0, np.nan)
)
elif {"high", "low", "close"}.issubset(series_map):
series_map["vwap"] = (
series_map["high"] + series_map["low"] + series_map["close"]
) / 3.0
elif "close" in series_map:
series_map["vwap"] = series_map["close"]
return series_map
def prepare_execution_inputs(
df: pd.DataFrame,
stock_col: str = "stock_code",
date_col: str = "trade_date",
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""长表行情 → execution 输入(prices + volumes 宽表)。
Args:
df: 长表行情(含 close / volume 列,Tushare rename 后)
stock_col: 股票代码列名
date_col: 日期列名
Returns:
(prices_wide, volumes_wide):
- prices_wide: date × stock_code,值=close
- volumes_wide: date × stock_code,值=volume(若无 volume 列则全 1.0)
Examples:
>>> df = pd.DataFrame({
... "stock_code": ["A", "A", "B", "B"],
... "trade_date": ["2024-01-01", "2024-01-02"] * 2,
... "close": [10.0, 11.0, 20.0, 21.0],
... "volume": [100.0, 200.0, 300.0, 400.0],
... })
>>> prices, volumes = prepare_execution_inputs(df)
>>> prices.shape
(2, 2)
"""
if df.empty:
return pd.DataFrame(), pd.DataFrame()
if "close" not in df.columns:
raise ValueError(f"prepare_execution_inputs: 缺 close 列,实际列={list(df.columns)}")
prices = long_to_wide(df, value_col="close", date_col=date_col, stock_col=stock_col)
if "volume" in df.columns:
volumes = long_to_wide(df, value_col="volume", date_col=date_col, stock_col=stock_col)
else:
logger.warning("prepare_execution_inputs: 无 volume 列,volumes 全 1.0")
volumes = pd.DataFrame(1.0, index=prices.index, columns=prices.columns)
return prices, volumes
def load_qtdb_daily(
stock_codes: Sequence[str],
begin_date: str,
end_date: str | None = None,
with_adj: bool = False,
vwap_method: str = "amount_vol",
) -> pd.DataFrame:
"""便捷加载:qtdb_pro.hq_daily 长表行情(Tushare rename + vwap + 可选复权)。
Args:
stock_codes: 股票代码列表
begin_date: 起始日期(YYYY-MM-DD)
end_date: 结束日期(可选)
with_adj: 是否 join hq_adj_factor 前复权
vwap_method: vwap 代理方法(amount_vol / typical / close)
Returns:
长表 DataFrame:
stock_code / trade_date / open / high / low / close / pre_close /
pct_chg / volume / amount / vwap(+ adj_factor 若 with_adj)
Note:
- 需要真实 ClickHouse 连接(.env 配置 CLICKHOUSE_QTDB_*)
- 无连接时返回空 DataFrame + 告警
"""
try:
from src.data.clickhouse import qtdb_query
except ImportError:
logger.warning("load_qtdb_daily: src.data.clickhouse 不可用,返回空表")
return pd.DataFrame()
where = "ts_code IN %(codes)s AND trade_date >= %(begin)s"
params: dict[str, Any] = {"codes": tuple(stock_codes), "begin": begin_date}
if end_date:
where += " AND trade_date <= %(end)s"
params["end"] = end_date
sql = (
"SELECT ts_code, trade_date, open, high, low, close, pre_close, "
"pct_chg, vol, amount "
f"FROM qtdb_pro.hq_daily WHERE {where} ORDER BY stock_code, trade_date"
)
try:
result = qtdb_query(sql, params)
if hasattr(result, "to_df"):
df = result.to_df()
else:
df = pd.DataFrame(result)
except Exception as e:
logger.warning("load_qtdb_daily: 查询失败(返回空表): %s", e)
return pd.DataFrame()
if df.empty:
return df
df = rename_tushare_columns(df)
df = add_vwap_proxy(df, method=vwap_method)
if with_adj:
try:
adj_sql = (
"SELECT ts_code, trade_date, adj_factor "
f"FROM qtdb_pro.hq_adj_factor WHERE {where} ORDER BY stock_code, trade_date"
)
adj_result = qtdb_query(adj_sql, params)
adj_df = (
adj_result.to_df() if hasattr(adj_result, "to_df") else pd.DataFrame(adj_result)
)
if not adj_df.empty:
adj_df = adj_df.rename(columns={"ts_code": "stock_code"})
df = apply_adj_factor(df, adj_df, mode="qfq")
except Exception as e:
logger.warning("load_qtdb_daily: 复权失败(返回未复权): %s", e)
return df
+675
View File
@@ -0,0 +1,675 @@
"""执行仿真骨架(v1.2.0 Phase 0)。
为 micro 维度的选股 / 择时项目提供**执行现实性**模拟。
设计动机(参见 docs/MIGRATION_PLAN.md):
- 自研 backtest.py 是 weight-based 周期调仓,无单笔撮合仿真
- micro 10 个项目(minsheng_portfolio / stock_select_in_industry / baotuan_portfolio / crowd_factor 等)
已有 signal_minsheng_value 这样的信号函数,缺执行层
借鉴 hikyuu SG/MM/CN/PG 部件化思想(不引入 hikyuu 框架):
- ExecutionConfig:佣金 + 印花税 + 滑点 + 最小交易额 + 止损/止盈阈值
- simulate_execution():从目标权重 → 实际成交金额(应用成本/滑点)
- simulate_multi_day():多日组合仿真(NAV 序列 + 调仓记录)
- check_stop_loss_take_profit():止损/止盈触发判定
- run_end_to_end_poc():signal → 调仓 → 执行 → NAV 端到端 POC
源参考:hikyuu 2.8.1 部件化设计思想(https://github.com/fasiondog/hikyuu),Apache 2.0 License。
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
import pandas as pd
@dataclass(frozen=True)
class ExecutionConfig:
"""单笔交易执行配置。
单位约定:
- bps (basis points) = 1/10000
- 典型 A 股:commission_bps=3(双边),stamp_tax_bps=10(卖出),slippage_bps=5
- min_trade_amount: 最小成交金额(元),低于此值的交易跳过
- stop_loss_pct / take_profit_pct: 个股级别止损/止盈阈值(小数,0.10 = 10%)
- 触发时机:每日 close 后检查持仓股的成本价 vs 现价
- 触发动作:当日收盘价全部卖出(产生印花税)
- 默认 inf = 关闭该条件
- enable_t1_settlement: T+1 结算(买入当日不能卖)
- price_limit_up_pct / price_limit_down_pct: 涨跌停限制(小数,0.10 = ±10%)
- 用于过滤因涨跌停而无法成交的卖出信号
- max_volume_pct: 单笔成交量占当日总成交的比例上限(防部分成交假设)
- bid_ask_spread_bps: 买卖价差(额外滑点)
"""
commission_bps: float = 3.0
stamp_tax_bps: float = 10.0
slippage_bps: float = 5.0
min_trade_amount: float = 100.0
stop_loss_pct: float = float("inf")
take_profit_pct: float = float("inf")
enable_t1_settlement: bool = False
price_limit_up_pct: float = 0.10 # A 股主板 ±10%
price_limit_down_pct: float = 0.10
max_volume_pct: float = 1.0 # 1.0 = 不限制
bid_ask_spread_bps: float = 0.0 # 默认无买卖价差
def __post_init__(self) -> None:
"""参数合法性校验。"""
if self.commission_bps < 0:
raise ValueError(f"commission_bps must be non-negative, got {self.commission_bps}")
if self.stamp_tax_bps < 0:
raise ValueError(f"stamp_tax_bps must be non-negative, got {self.stamp_tax_bps}")
if self.slippage_bps < 0:
raise ValueError(f"slippage_bps must be non-negative, got {self.slippage_bps}")
if self.min_trade_amount < 0:
raise ValueError(f"min_trade_amount must be non-negative, got {self.min_trade_amount}")
if self.stop_loss_pct <= 0:
raise ValueError(f"stop_loss_pct must be positive (or inf), got {self.stop_loss_pct}")
if self.take_profit_pct <= 0:
raise ValueError(
f"take_profit_pct must be positive (or inf), got {self.take_profit_pct}"
)
if not 0 <= self.price_limit_up_pct <= 1:
raise ValueError(f"price_limit_up_pct must be in [0, 1], got {self.price_limit_up_pct}")
if not 0 <= self.price_limit_down_pct <= 1:
raise ValueError(
f"price_limit_down_pct must be in [0, 1], got {self.price_limit_down_pct}"
)
if not 0 < self.max_volume_pct <= 1:
raise ValueError(f"max_volume_pct must be in (0, 1], got {self.max_volume_pct}")
if self.bid_ask_spread_bps < 0:
raise ValueError(
f"bid_ask_spread_bps must be non-negative, got {self.bid_ask_spread_bps}"
)
@dataclass(frozen=True)
class ExecutionResult:
"""单笔交易执行结果。"""
stock_code: str
target_value: float # 目标成交金额(元)
executed_value: float # 实际成交金额(元,已含滑点)
commission: float # 佣金(元)
stamp_tax: float # 印花税(元)
slippage_cost: float # 滑点成本(元)
total_cost: float # 总成本 = 佣金 + 印花税 + 滑点
net_cash_flow: float # 净现金流(买入为负,卖出为正)
partial_fill_pct: float = 1.0 # 实际成交占目标的比例(1.0 = 全部成交)
blocked_reason: str = "" # 阻塞原因(如涨跌停停牌)
def _apply_costs(
target_value: float,
is_buy: bool,
config: ExecutionConfig,
) -> tuple[float, float, float, float]:
"""应用成本 + 滑点,返回 (executed_value, commission, stamp_tax, slippage_cost)。"""
abs_value = abs(target_value)
# 滑点:买入加价,卖出降价
slippage_rate = config.slippage_bps / 10000.0
if is_buy:
executed_value = abs_value * (1.0 + slippage_rate)
else:
executed_value = abs_value * (1.0 - slippage_rate)
# 佣金(双边)
commission = executed_value * config.commission_bps / 10000.0
# 印花税(仅卖出)
stamp_tax = executed_value * config.stamp_tax_bps / 10000.0 if not is_buy else 0.0
slippage_cost = abs(executed_value - abs_value)
return executed_value, commission, stamp_tax, slippage_cost
def simulate_execution(
target_weights: Mapping[str, float],
portfolio_value: float,
config: ExecutionConfig | None = None,
) -> list[ExecutionResult]:
"""从目标权重到实际执行结果的仿真。
Args:
target_weights: {stock_code: target_weight},权重之和应该 ≈ 1.0(允许微小偏差)
portfolio_value: 当前组合总市值(元)
config: 执行配置(默认 A 股典型值)
Returns:
每个 stock_code 一个 ExecutionResult。
Examples:
>>> cfg = ExecutionConfig()
>>> targets = {"000001.SZ": 0.5, "600000.SH": 0.5}
>>> results = simulate_execution(targets, portfolio_value=1_000_000, config=cfg)
>>> len(results)
2
>>> results[0].stock_code
'000001.SZ'
"""
if config is None:
config = ExecutionConfig()
results: list[ExecutionResult] = []
for stock_code, weight in target_weights.items():
target_value = weight * portfolio_value
# 跳过零额与低于最小成交额
if abs(target_value) < config.min_trade_amount:
continue
is_buy = target_value > 0
executed_value, commission, stamp_tax, slippage_cost = _apply_costs(
target_value,
is_buy,
config,
)
total_cost = commission + stamp_tax + slippage_cost
net_cash_flow = (
-(executed_value + commission + stamp_tax)
if is_buy
else (executed_value - commission - stamp_tax)
)
results.append(
ExecutionResult(
stock_code=stock_code,
target_value=target_value,
executed_value=executed_value,
commission=commission,
stamp_tax=stamp_tax,
slippage_cost=slippage_cost,
total_cost=total_cost,
net_cash_flow=net_cash_flow,
)
)
return results
def total_costs(results: list[ExecutionResult]) -> float:
"""汇总所有成交的总成本。"""
return sum(r.total_cost for r in results)
def total_turnover(results: list[ExecutionResult]) -> float:
"""汇总所有成交的成交金额(用于换手率计算)。"""
return sum(r.executed_value for r in results)
# ── v1.2.0 Phase 1:多日仿真 / 止损止盈 / 端到端 POC ─────
def check_t1_settlement(
today_buy_date: str,
today_sell_date: str,
enable_t1: bool = False,
) -> bool:
"""检查 T+1 结算约束:买入当日能否卖出。
Args:
today_buy_date: 买入日期(YYYY-MM-DD)
today_sell_date: 卖出日期(YYYY-MM-DD)
enable_t1: 是否启用 T+1 结算
Returns:
True = 可以卖,False = T+1 限制不能卖
"""
if not enable_t1:
return True
return today_buy_date < today_sell_date
def check_price_limit(
prev_close: float,
current_price: float,
config: ExecutionConfig,
is_sell: bool = True,
) -> tuple[bool, str]:
"""检查涨跌停约束:能否成交。
A 股规则:当日成交价相对昨收 ±price_limit(主板 ±10%,创业板 ±20%)。
涨停日无法买入(封板),跌停日无法卖出(封板)。
Args:
prev_close: 昨日收盘价
current_price: 当日目标价
config: 执行配置
is_sell: True=卖出 / False=买入
Returns:
(can_trade, reason)
- (True, "") 可以成交
- (False, "limit_up_blocked_buy") 涨停不能买
- (False, "limit_down_blocked_sell") 跌停不能卖
"""
if prev_close <= 0:
return True, ""
upper_limit = prev_close * (1.0 + config.price_limit_up_pct)
lower_limit = prev_close * (1.0 - config.price_limit_down_pct)
if not is_sell and current_price > upper_limit:
return False, "limit_up_blocked_buy"
if is_sell and current_price < lower_limit:
return False, "limit_down_blocked_sell"
return True, ""
def apply_volume_constraint(
target_shares: float,
daily_volume_shares: float,
config: ExecutionConfig,
) -> float:
"""应用成交量约束:单笔最大成交 = daily_volume * max_volume_pct。
Args:
target_shares: 目标股数
daily_volume_shares: 当日总成交量(股)
config: 执行配置
Returns:
实际可成交股数(受 max_volume_pct 限制)
"""
if daily_volume_shares <= 0:
return 0.0
max_shares = daily_volume_shares * config.max_volume_pct
return min(target_shares, max_shares)
def apply_bid_ask_spread(
target_value: float,
is_buy: bool,
config: ExecutionConfig,
) -> float:
"""应用买卖价差(额外滑点)。
买入:实际支付 = target + spread
卖出:实际获得 = target - spread
Args:
target_value: 目标成交金额
is_buy: True=买入
config: 执行配置
Returns:
调整后的成交金额(含买卖价差)
"""
spread_rate = config.bid_ask_spread_bps / 10000.0
if is_buy:
return target_value * (1.0 + spread_rate)
return target_value * (1.0 - spread_rate)
def check_stop_loss_take_profit(
cost_basis: float,
current_price: float,
config: ExecutionConfig,
) -> str | None:
"""检查个股是否触发止损/止盈。
Args:
cost_basis: 持仓成本价(每股)
current_price: 当前价格(每股)
config: 执行配置(stop_loss_pct / take_profit_pct)
Returns:
'stop_loss' / 'take_profit' / None(未触发)
Examples:
>>> cfg = ExecutionConfigstop_loss_pct=0.10)
>>> check_stop_loss_take_profit(100.0, 89.0, cfg)
'stop_loss'
>>> check_stop_loss_take_profit(100.0, 89.5, cfg)
None
"""
if cost_basis <= 0:
return None # 无成本价 → 不触发
pnl_pct = (current_price - cost_basis) / cost_basis
# 止损:亏损 >= 阈值
if pnl_pct <= -config.stop_loss_pct:
return "stop_loss"
# 止盈:盈利 >= 阈值
if pnl_pct >= config.take_profit_pct:
return "take_profit"
return None
@dataclass(frozen=True)
class DailyPosition:
"""单日持仓快照。"""
date: str
cash: float
holdings: dict[str, float] # {stock_code: shares}
portfolio_value: float # cash + sum(holdings * close_price)
@dataclass(frozen=True)
class DailyExecution:
"""单日执行记录。"""
date: str
executions: list[ExecutionResult]
nav_before: float
nav_after: float
rebalance_triggered: bool
def simulate_multi_day(
target_weights_history: list[tuple[str, dict[str, float]]],
price_history: list[tuple[str, dict[str, float]]],
initial_cash: float,
config: ExecutionConfig | None = None,
) -> list[DailyPosition]:
"""多日组合仿真(NAV 序列)。
Args:
target_weights_history: [(date, {stock_code: target_weight})]
price_history: [(date, {stock_code: close_price})],与 target_weights 同长度、同 date
initial_cash: 初始资金(元)
config: 执行配置
Returns:
DailyPosition 列表(每日 NAV 快照)。
Note:
- 调仓频率 = target_weights_history 的频率(每日 / 每周 / 每月都行)
- 每日先按当日 close 估值,再按当日 target 调仓(下一交易日生效)
- 此处简化:调仓使用当日 close 价格
"""
if config is None:
config = ExecutionConfig()
if len(target_weights_history) != len(price_history):
raise ValueError("target_weights_history and price_history must have same length")
if not target_weights_history:
return []
cash = initial_cash
holdings: dict[str, float] = {}
positions: list[DailyPosition] = []
for (date, targets), (_, prices) in zip(target_weights_history, price_history, strict=True):
# 1) 先按当日收盘价估值
portfolio_value = cash + sum(
shares * prices.get(code, 0.0) for code, shares in holdings.items()
)
positions.append(
DailyPosition(
date=date,
cash=cash,
holdings=dict(holdings),
portfolio_value=portfolio_value,
)
)
# 2) 计算 effective_targets(包含需要平仓的零权重)
effective_targets: dict[str, float] = dict(targets)
for held_code in holdings:
if held_code not in effective_targets:
effective_targets[held_code] = 0.0
# 3) 调仓(只对非零目标调用 simulate_execution)
non_zero_targets = {k: v for k, v in effective_targets.items() if v != 0}
results = simulate_execution(non_zero_targets, portfolio_value, config)
# 4) 处理零目标(平仓):构造 ExecutionResult,shares = held(全部卖出)
for stock_code, weight in effective_targets.items():
if weight == 0 and stock_code in holdings and holdings[stock_code] > 0:
price = prices.get(stock_code, 0.0)
if price > 0:
held = holdings[stock_code]
# 全部卖出:target_shares = held
# executed_value = held * price(考虑滑点)
slippage_factor = 1.0 - config.slippage_bps / 10000.0
target_value = -held * price
executed_value = target_value * slippage_factor
commission = abs(executed_value) * config.commission_bps / 10000.0
stamp_tax = abs(executed_value) * config.stamp_tax_bps / 10000.0
slippage_cost = abs(executed_value - target_value)
# 标记净卖出 shares = held
results.append(
ExecutionResult(
stock_code=stock_code,
target_value=target_value,
executed_value=executed_value,
commission=commission,
stamp_tax=stamp_tax,
slippage_cost=slippage_cost,
total_cost=commission + stamp_tax + slippage_cost,
net_cash_flow=executed_value - commission - stamp_tax,
)
)
# 5) 应用执行结果到持仓
for r in results:
cost = r.executed_value + r.commission + r.stamp_tax
proceeds = r.executed_value - r.commission - r.stamp_tax
price = prices.get(r.stock_code, 0.0)
if r.target_value > 0:
# 买入:shares = 正数 executed_value / price,cash 减少 cost
shares = r.executed_value / price if price > 0 else 0.0
holdings[r.stock_code] = holdings.get(r.stock_code, 0.0) + shares
cash -= cost
else:
# 卖出:cash 增加 proceeds 的绝对值(proceeds 本是负的)
held = holdings.get(r.stock_code, 0.0)
if held > 0:
# 如果是 zero-target 触发的全卖(target_value 与持仓市值近似),全部卖出
if abs(r.target_value) >= held * price * 0.95:
sell_shares = held
else:
target_shares = abs(r.executed_value) / price if price > 0 else held
sell_shares = min(held, target_shares)
holdings[r.stock_code] = held - sell_shares
if holdings[r.stock_code] < 1e-6:
del holdings[r.stock_code]
# proceeds 是负的(target_value 负),cash += proceeds 实际是减去
# 但卖出是现金流入,所以应该 cash += abs(proceeds)
cash += abs(proceeds)
return positions
def run_end_to_end_poc(
signals: list[tuple[str, dict[str, float]]], # [(date, {stock: weight})]
prices: list[tuple[str, dict[str, float]]], # [(date, {stock: close})]
initial_cash: float = 1_000_000.0,
config: ExecutionConfig | None = None,
) -> dict[str, Any]:
"""端到端 POC:信号 → 调仓 → 执行 → NAV(micro 维度)。
Args:
signals: 信号序列(每日期目标权重)
prices: 价格序列(每日期收盘价)
initial_cash: 初始资金
config: 执行配置
Returns:
dict 含 positions / nav_series / total_costs / total_turnover / total_rebalances
Examples:
>>> signals = [("d1", {"A": 0.5, "B": 0.5}), ("d2", {"A": 0.6, "C": 0.4})]
>>> prices = [("d1", {"A": 10, "B": 20}), ("d2", {"A": 11, "B": 21, "C": 30})]
>>> result = run_end_to_end_poc(signals, prices, 1_000_000)
>>> result["nav_series"][-1] > result["nav_series"][0]
True
"""
if config is None:
config = ExecutionConfig()
if len(signals) != len(prices):
raise ValueError("signals and prices must have same length")
positions = simulate_multi_day(signals, prices, initial_cash, config)
nav_series = pd.Series(
[p.portfolio_value for p in positions], index=[p.date for p in positions]
)
# 计算 total_costs / total_turnover(重放所有执行)
total_cost_acc = 0.0
total_turnover_acc = 0.0
rebalance_count = 0
cash = initial_cash
holdings: dict[str, float] = {}
for (date, targets), (_, price_map) in zip(signals, prices, strict=True):
portfolio_value = cash + sum(
shares * price_map.get(code, 0.0) for code, shares in holdings.items()
)
if targets:
rebalance_count += 1
# 自动平仓:持仓但不在 target 中的股票
effective_targets: dict[str, float] = dict(targets)
for held_code in holdings:
if held_code not in effective_targets:
effective_targets[held_code] = 0.0
results = simulate_execution(effective_targets, portfolio_value, config)
total_cost_acc += total_costs(results)
total_turnover_acc += total_turnover(results)
for r in results:
cost = r.executed_value + r.commission + r.stamp_tax
proceeds = r.executed_value - r.commission - r.stamp_tax
if r.target_value > 0:
shares = (
r.executed_value / price_map[r.stock_code]
if price_map[r.stock_code] > 0
else 0.0
)
holdings[r.stock_code] = holdings.get(r.stock_code, 0.0) + shares
cash -= cost
else:
held = holdings.get(r.stock_code, 0.0)
if held > 0:
sell_shares = min(
held,
abs(r.executed_value / price_map[r.stock_code])
if price_map[r.stock_code] > 0
else held,
)
holdings[r.stock_code] = held - sell_shares
if holdings[r.stock_code] < 1e-6:
del holdings[r.stock_code]
cash += proceeds
return {
"positions": positions,
"nav_series": nav_series,
"total_costs": total_cost_acc,
"total_turnover": total_turnover_acc,
"total_rebalances": rebalance_count,
"final_portfolio_value": nav_series.iloc[-1] if len(nav_series) > 0 else initial_cash,
"return_pct": ((nav_series.iloc[-1] / initial_cash) - 1) * 100
if len(nav_series) > 0
else 0.0,
}
# ── v1.2.0 实战增强:连续调仓 + 已实现盈亏 ─────
@dataclass(frozen=True)
class DailyPnL:
"""单日盈亏拆解。"""
date: str
realized_pnl: float # 已实现盈亏(卖出时锁定)
unrealized_pnl: float # 未实现盈亏(持仓市值变化)
total_pnl: float # 总额
def simulate_with_daily_data(
prices: pd.DataFrame,
volumes: pd.DataFrame | None = None,
target_weights_history: list[Mapping[str, float]] | None = None,
initial_cash: float = 1_000_000.0,
config: ExecutionConfig | None = None,
) -> list[DailyPosition]:
"""连续调仓仿真:传入 wide DataFrame 自动逐日推进(无需构造 signals/prices 元组)。
Args:
prices: 收盘价 wide DataFrame(index=date,columns=stock_code)
volumes: 成交量 wide DataFrame(同 shape,可选)
target_weights_history: 每日目标权重 list(按 prices.index 顺序;None = 全部等权持仓)
initial_cash: 初始资金
config: 执行配置
Returns:
DailyPosition 列表
Note:
- target_weights_history[i] 对应 prices.index[i] 的目标权重
- 默认行为(None):每日按等权 (1/n_stocks) 配置买入
- 调用 simulate_multi_day() 内部实现
"""
if config is None:
config = ExecutionConfig()
if prices.empty:
return []
dates = list(prices.index)
n_stocks = len(prices.columns)
if target_weights_history is None:
# 默认:每日等权
target_weights_history = [dict.fromkeys(prices.columns, 1.0 / n_stocks) for _ in dates]
if len(target_weights_history) != len(dates):
raise ValueError(
f"target_weights_history length {len(target_weights_history)} "
f"!= dates length {len(dates)}"
)
signals: list[tuple[str, dict[str, float]]] = []
prices_list: list[tuple[str, dict[str, float]]] = []
for date, targets in zip(dates, target_weights_history, strict=True):
signals.append((str(date), dict(targets)))
price_today = prices.loc[date]
price_dict = {code: float(p) for code, p in price_today.items() if pd.notna(p) and p > 0}
prices_list.append((str(date), price_dict))
return simulate_multi_day(signals, prices_list, initial_cash, config)
def compute_realized_pnl(positions: list[DailyPosition]) -> list[DailyPnL]:
"""计算每日盈亏拆解(已实现 vs 未实现)。
简化版(无成本基础追踪):
realized_pnl[t] = cash_change[t] + holdings_value_change[t]
≈ 卖出时锁定的部分 + 红利等
unrealized_pnl[t] = nav_change[t] - realized_pnl[t]
total_pnl[t] = realized_pnl[t] + unrealized_pnl[t]
Args:
positions: simulate_multi_day / simulate_with_daily_data 的输出
Returns:
DailyPnL 列表(与 positions 同长度)
Note:
真正的成本基础(cost basis)追踪需要额外记录每笔成交的成本价 + 卖出时按 FIFO/LIFO 计算;
本函数提供近似估算。完整 PnL 拆解需要更精细的实现。
"""
if not positions:
return []
pnl_list: list[DailyPnL] = []
prev_value = positions[0].portfolio_value
prev_cash = positions[0].cash
for pos in positions:
cur_value = pos.portfolio_value
cash_change = pos.cash - prev_cash
nav_change = cur_value - prev_value
# cash_change 与 nav_change 之差 = unrealized(含价格变化)
# 简化:现金净流入 = realized(买入负,卖出正)
realized = cash_change
unrealized = nav_change - cash_change
total_pnl = nav_change
pnl_list.append(
DailyPnL(
date=pos.date,
realized_pnl=round(float(realized), 2),
unrealized_pnl=round(float(unrealized), 2),
total_pnl=round(float(total_pnl), 2),
)
)
prev_value = cur_value
prev_cash = pos.cash
return pnl_list
__all__ = [
"ExecutionConfig",
"ExecutionResult",
"simulate_execution",
"total_costs",
"total_turnover",
"check_stop_loss_take_profit",
"check_t1_settlement",
"check_price_limit",
"apply_volume_constraint",
"apply_bid_ask_spread",
"DailyPosition",
"DailyExecution",
"simulate_multi_day",
"run_end_to_end_poc",
"DailyPnL",
"simulate_with_daily_data",
"compute_realized_pnl",
]
+567
View File
@@ -0,0 +1,567 @@
"""因子计算方法库 —— 标准因子公式的统一定义。
源 49 个 proj 里散落重复实现的方法:
- turnover / ic(crowd_factor / stock_crowd_ratio 各算各的)
- winsorize(fund_mapping / hot_indicator_stockfund 手写 clip)
- skewness / kurtosis(beta_score / cta_factors / fund_mapping 都用 pd.Series.skew)
- ols_regress(mf_indnstyle / hot_indicator_stockfund / newstock_factor)
抽取到共享层后,所有子项目统一调用,避免重复实现 + 公式不一致。
## 用法
```python
from quant_engine.factor_library import (
turnover, # 组合 / 因子换手率
ic_summary, # 因子 IC + ICIR + 衰减
winsorize, # 缩尾处理(按分位 clip)
skewness, # 偏度(Fisher 定义)
kurtosis, # 峰度(Fisher 定义,超额峰度)
jb_test, # Jarque-Bera 正态检验
ols_regress, # 单因子 OLS 回归(含 t 检验 + R²)
cross_sectional_momentum, # 截面动量(rank-based)
time_series_momentum, # 时序动量(sign-based)
)
# 1. 组合换手率(每个调仓日的 turnover)
to = turnover(weights_df, periods_per_year=252)
# 2. 因子 IC + ICIR
ic_df = ic_summary(factor, fwd_returns, periods=(1, 5, 21))
# 3. 缩尾处理
clean = winsorize(returns, lower=0.01, upper=0.99)
# 4. 高阶矩 + 正态检验
sk = skewness(returns)
kt = kurtosis(returns)
jb = jb_test(returns) # 返回 (statistic, p_value)
# 5. 单因子 OLS 回归(含 t 值 / R² / 残差)
res = ols_regress(factor, returns)
# res.alpha, res.beta, res.t_stat, res.r_squared, res.resid
```
## 公式选型说明
- **skewness / kurtosis**:用 Fisher 定义(pd.Series.skew() / .kurt() 默认就是)。
跟样本矩定义一致,不除以 (n-1) 修正,结果兼容 scipy.stats。
- **ic**:默认 Pearson;提供 spearman_ic 走 rank IC(对非线性更稳)。
- **turnover**:定义为 (|w_t - w_{t-1}|).sum() / 2(含多空对冲时除以 2 才是
实际换手金额;纯多头组合可直接用 sum)。
- **winsorize**:按分位 clip 到 [lower, upper],避免极端值干扰回归 / 排序。
- **ols_regress**:用 numpy.linalg.lstsq 实现,不依赖 statsmodels(轻依赖)。
返回命名元组,便于解构。
"""
from __future__ import annotations
from collections import namedtuple
from collections.abc import Mapping, Sequence
import numpy as np
import pandas as pd
from scipy import stats
from quant_engine.logging import get_logger
logger = get_logger(__name__)
__all__ = [
"turnover",
"ic_summary",
"spearman_ic",
"winsorize",
"skewness",
"kurtosis",
"jb_test",
"ols_regress",
"cross_sectional_momentum",
"time_series_momentum",
"rolling_zscore",
"rolling_annual_vol",
"cross_sectional_pct_rank",
"annualized_sharpe",
"FACTOR_DIRECTIONS",
"apply_factor_direction",
"cross_sectional_rank_with_direction",
]
# ── 换手率 ──────────────────────────────────
def turnover(
weights: pd.DataFrame,
divide_by_two: bool = True,
) -> pd.Series:
"""调仓日换手率:sum(|w_t - w_{t-1}|) / [2 if divide_by_two else 1]。
Args:
weights: 行=日期, 列=资产, 值=权重(多空组合总和可以为 0)。
divide_by_two: True=多空对冲口径(卖出 + 买入 = 实际换手金额);
False=纯多头口径(卖 + 买 = 总成交)。
Returns:
pd.Series, index=weights.index[1:], 每个调仓日的换手率。
Example:
weights = pd.DataFrame({...}, index=dates)
to = turnover(weights)
avg_annual_turnover = to.mean() * 252 # 假设日频调仓
"""
if weights.empty or len(weights) < 2:
return pd.Series(dtype=float)
diff = weights.diff().abs()
denom = 2.0 if divide_by_two else 1.0
to = diff.sum(axis=1) / denom
return to.iloc[1:] # 第一行是 NaN
# ── IC(信息系数) ────────────────────────────
def _ic_one(
factor: pd.Series,
returns: pd.Series,
method: str = "pearson",
) -> float:
"""单期 IC。factor / returns 同 index,按对应日期算截面相关(这里是 1-d)。"""
df = pd.concat([factor, returns], axis=1).dropna()
if len(df) < 3:
return float("nan")
if method == "pearson":
return float(df.corr().iloc[0, 1])
if method == "spearman":
return float(df.corr(method="spearman").iloc[0, 1])
raise ValueError(f"method={method} not supported (pearson / spearman)")
def spearman_ic(factor: pd.Series, returns: pd.Series) -> float:
"""Spearman rank IC(单期)。"""
return _ic_one(factor, returns, method="spearman")
def ic_summary(
factor: pd.Series,
fwd_returns: pd.Series,
periods: Sequence[int] = (1,),
method: str = "pearson",
) -> pd.DataFrame:
"""因子 IC 摘要:多持有期 + mean / std / IR / t。
Args:
factor: 因子值(时序 or 截面同 index 都行;截面版按 index 对齐逐日算)。
fwd_returns: 前瞻收益(与 factor 同 index;periods=1 即 next-period)。
periods: 持有期(天),默认 (1,)。常见 (1, 5, 21) = 日 / 周 / 月。
method: pearson / spearman。
Returns:
DataFrame,行=持有期,列=[ic_mean, ic_std, icir, ic_t, ic_p, n]。
icir = ic_mean / ic_std(信息比率 = mean IC / IC 波动率)。
ic_t = ic_mean / (ic_std / sqrt(n))(t 统计量)。
ic_p = 双侧 t 检验 p 值。
"""
rows = []
for p in periods:
if p == 1:
fwd = fwd_returns
else:
fwd = fwd_returns.rolling(p).sum().shift(-p)
# 计算每期 IC(这里假设是时序对齐;如果 factor 是截面,会因 series 对齐到 nan)
# 简化:直接按 index 对齐后算一个总 IC(截面日序列需要 caller 自行处理)
ic = _ic_one(factor, fwd, method=method)
n = pd.concat([factor, fwd], axis=1).dropna().shape[0]
if not np.isfinite(ic) or n < 3:
rows.append((p, np.nan, np.nan, np.nan, np.nan, np.nan, n))
continue
ic_std = np.nan # 单期版本没有 std
icir = np.nan
ic_t = np.nan
ic_p = np.nan
rows.append((p, ic, ic_std, icir, ic_t, ic_p, n))
return pd.DataFrame(
rows, columns=["period", "ic_mean", "ic_std", "icir", "ic_t", "ic_p", "n"]
).set_index("period")
# ── 缩尾 ──────────────────────────────────────
def winsorize(
series: pd.Series,
lower: float = 0.01,
upper: float = 0.99,
) -> pd.Series:
"""按分位缩尾:clip 到 [Q_lower, Q_upper]。
Args:
series: 输入序列(NaN 保留,不参与分位计算)。
lower: 下分位(默认 0.01)。
upper: 上分位(默认 0.99)。
Returns:
clip 后的 pd.Series,dtype 与输入一致。
"""
if series.empty:
return series.copy()
lo = series.quantile(lower)
hi = series.quantile(upper)
return series.clip(lower=lo, upper=hi)
# ── 高阶矩 + 正态检验 ────────────────────────
def skewness(series: pd.Series) -> float:
"""偏度(Fisher 定义 = 三阶中心矩 / 标准差³)。
与 pd.Series.skew() / scipy.stats.skew() 一致(bias=False)。
"""
clean = series.dropna()
if len(clean) < 3:
return float("nan")
return float(clean.skew())
def kurtosis(series: pd.Series) -> float:
"""超额峰度(Fisher 定义 = 四阶中心矩 / 标准差⁴ - 3)。
与 pd.Series.kurt() / scipy.stats.kurtosis() 一致(bias=False)。
正态分布超额峰度 = 0。
"""
clean = series.dropna()
if len(clean) < 4:
return float("nan")
return float(clean.kurt())
def jb_test(series: pd.Series) -> tuple[float, float]:
"""Jarque-Bera 正态性检验。
原假设:样本服从正态分布。
检验统计量 JB = n/6 * (S² + K²/4),其中 S = skewness, K = kurtosis。
大样本下 JB ~ chi2(2)。
Returns:
(jb_statistic, p_value)。p 值小 → 拒绝正态。
"""
clean = series.dropna()
n = len(clean)
if n < 8:
return (np.nan, np.nan)
s = skewness(clean)
k = kurtosis(clean)
jb = n / 6.0 * (s**2 + k**2 / 4.0)
p = 1.0 - stats.chi2.cdf(jb, df=2)
return (float(jb), float(p))
# ── OLS 回归 ──────────────────────────────────
OlsResult = namedtuple(
"OlsResult",
["alpha", "beta", "alpha_t", "beta_t", "r_squared", "n", "resid"],
)
def ols_regress(
y: pd.Series,
x: pd.Series | pd.DataFrame,
add_constant: bool = True,
) -> OlsResult:
"""单因子 OLS 回归:y = alpha + beta * x + eps。
用 numpy.linalg.lstsq 实现(不依赖 statsmodels),适合轻量场景。
Args:
y: 因变量(pd.Series)。
x: 自变量(pd.Series 单因子 / pd.DataFrame 多因子,按列对齐)。
add_constant: True=加截距项 alpha。
Returns:
OlsResult: alpha, beta(pd.Series 多因子时), alpha_t, beta_t, r_squared, n, resid。
Example:
res = ols_regress(returns, factor)
print(f"alpha={res.alpha:.4f}, beta={res.beta:.4f}, t={res.beta_t:.2f}")
"""
if isinstance(x, pd.Series):
x = x.to_frame()
df = pd.concat([y.rename("__y__"), x], axis=1).dropna()
if df.shape[0] < (x.shape[1] + (1 if add_constant else 0) + 2):
return OlsResult(
alpha=np.nan,
beta=pd.Series(dtype=float),
alpha_t=np.nan,
beta_t=pd.Series(dtype=float),
r_squared=np.nan,
n=df.shape[0],
resid=pd.Series(dtype=float),
)
y_arr = df["__y__"].values
x_arr = df.drop(columns="__y__").values
if add_constant:
x_arr = np.column_stack([np.ones(len(y_arr)), x_arr])
coef, _resid_arr, _rank, _sv = np.linalg.lstsq(x_arr, y_arr, rcond=None)
y_hat = x_arr @ coef
resid_full = y_arr - y_hat
n = len(y_arr)
k = x_arr.shape[1]
ss_res = float((resid_full**2).sum())
ss_tot = float(((y_arr - y_arr.mean()) ** 2).sum())
r_sq = 1.0 - ss_res / ss_tot if ss_tot > 0 else np.nan
sigma2 = ss_res / max(n - k, 1)
# 协方差矩阵 = sigma2 * (X'X)^-1
xtx_inv = np.linalg.inv(x_arr.T @ x_arr) if sigma2 > 0 else np.full((k, k), np.nan)
se = np.sqrt(np.diag(xtx_inv) * sigma2)
t_vals = coef / se if sigma2 > 0 else np.full_like(coef, np.nan)
if add_constant:
alpha = float(coef[0])
alpha_t = float(t_vals[0])
beta = pd.Series(coef[1:], index=x.columns, name="beta")
beta_t = pd.Series(t_vals[1:], index=x.columns, name="beta_t")
else:
alpha = np.nan
alpha_t = np.nan
beta = pd.Series(coef, index=x.columns, name="beta")
beta_t = pd.Series(t_vals, index=x.columns, name="beta_t")
resid_series = pd.Series(resid_full, index=df.index, name="resid")
return OlsResult(
alpha=alpha,
beta=beta,
alpha_t=alpha_t,
beta_t=beta_t,
r_squared=r_sq,
n=n,
resid=resid_series,
)
# ── 动量 ──────────────────────────────────────
def cross_sectional_momentum(
prices: pd.DataFrame,
lookback: int = 20,
skip: int = 1,
) -> pd.DataFrame:
"""截面动量:过去 [skip+1, skip+lookback] 日累计收益(截面版)。
经典做法:跳过最近 skip 天(避免短期反转噪音),用 lookback 日累计收益。
Args:
prices: 行=日期, 列=资产, 值=收盘价。
lookback: 回看窗口(默认 20 日 = 1 月)。
skip: 跳过最近几日(默认 1 = 跳过昨日,避免隔夜反转)。
Returns:
DataFrame, shape 同 prices, 累计收益 = prices / prices.shift(skip+lookback) - 1。
"""
if prices.empty:
return prices.copy()
return prices / prices.shift(skip + lookback) - 1.0
def time_series_momentum(
returns: pd.Series,
lookback: int = 252,
) -> pd.Series:
"""时序动量信号:过去 lookback 日累计收益的符号(=1 / -1 / 0)。
Moskowitz-Ooi-Pedersen (2012) 的 TSMOM 定义:
sign(r_{t-lookback+1:t}),做多正、做空负。
Args:
returns: 单资产日收益(pd.Series)。
lookback: 默认 252 日 = 12 月。
Returns:
pd.Series, 值 ∈ {-1, 0, 1}。
"""
if returns.empty:
return returns.copy()
cum = (1.0 + returns).rolling(lookback).apply(np.prod, raw=True) - 1.0
return np.sign(cum).fillna(0.0).astype(int)
def rolling_zscore(
s: pd.Series,
window: int = 252,
min_periods: int | None = None,
ddof: int = 1,
) -> pd.Series:
"""滚动 Z-score 标准化。
7 个项目各有重复实现。统一参数:
- window(默认 252 日)
- min_periods(默认 window,需至少 window 个观测才出值)
- ddof(标准差自由度,默认 1 = 样本标准差,与 pandas `.std()` 一致;
设 0 = 总体标准差)
v1.1.0 统一 ddof 默认 0 → 1:
- factor_pipeline.rolling_zscore 此前已显式 ddof=1
- ind_crowd_ratio._rolling_zscore 等项目自定义实现也用 pandas 默认 ddof=1
- 改为 1 让 factor_library 与它们数值一致,消除跨项目"同一名字两个结果"
- pandas `.std()` 默认也是 ddof=1,符合用户预期
用法::
factor_z = rolling_zscore(factor, window=60)
group_z = df.groupby('group')['val'].transform(rolling_zscore, window=120)
"""
if min_periods is None:
min_periods = window
mean = s.rolling(window, min_periods=min_periods).mean()
std = s.rolling(window, min_periods=min_periods).std(ddof=ddof)
return (s - mean) / std.replace(0.0, np.nan)
def rolling_annual_vol(
returns: pd.Series,
window: int = 252,
min_periods: int | None = None,
trading_days: int = 252,
) -> pd.Series:
"""滚动年化波动率。
5 个项目各有重复实现。统一 parameters + 252 天年化。
"""
if min_periods is None:
min_periods = window // 2
return returns.rolling(window, min_periods=min_periods).std() * np.sqrt(trading_days)
def cross_sectional_pct_rank(
df: pd.DataFrame,
column: str,
group_col: str | None = None,
pct: bool = True,
ascending: bool = False,
method: str = "min",
) -> pd.Series:
"""截面百分位排名。
Args:
df: 输入 DataFrame
column: 要排名的列名
group_col: 分组列(如行业),None=全截面
pct: True=返回百分位 [0,1],False=返回序数排名
ascending: False=高分排 1(默认),True=低分排 1
method: 排名方法('min' / 'max' / 'average' / 'first')
用法::
df['pct'] = cross_sectional_pct_rank(df, 'momentum')
df['ind_rank'] = cross_sectional_pct_rank(df, 'value', group_col='industry', ascending=True)
"""
if df.empty or column not in df.columns:
return pd.Series(dtype=float)
if group_col:
return df.groupby(group_col)[column].rank(pct=pct, ascending=ascending, method=method)
return df[column].rank(pct=pct, ascending=ascending, method=method)
def annualized_sharpe(
returns: pd.Series,
trading_days: int = 252,
) -> float:
"""年化夏普比率(标量)。
统一 scalar 版本。滚动版本用 ``rolling_sharpe`` 或
``rolling_annual_vol`` + ``rolling_mean`` 组合。
"""
if returns.empty:
return 0.0
ann_ret = returns.mean() * trading_days
ann_vol = returns.std() * np.sqrt(trading_days)
return ann_ret / ann_vol if ann_vol > 1e-12 else 0.0
# ── 因子方向(v0.18.0 新增)────────────────────────────────────
# 统一处理"forward vs reverse"因子(如 pe_ttm 是 reverse,mom_20d 是 forward)
FACTOR_DIRECTIONS: dict[str, str] = {
# value factors (越低越好 → reverse)
"pe_ttm": "reverse",
"pe": "reverse",
"pb": "reverse",
"ps": "reverse",
"pcf": "reverse",
# momentum / growth (越高越好 → forward)
"mom_20d": "forward",
"mom_60d": "forward",
"roe": "forward",
"revenue_yoy": "forward",
"net_profit_yoy": "forward",
"gross_margin": "forward",
# turnover (越高越拥挤 → reverse 含义,但 raw value 越高越好 → forward)
"turnover_20d": "forward",
"turnover_rate": "reverse", # 拥挤度视角
}
def apply_factor_direction(
factor: pd.Series,
direction: str = "auto",
) -> pd.Series:
"""按因子方向调整(reverse 因子取负,与 forward 因子同向)。
Args:
factor: 原始因子值
direction: "forward" / "reverse" / "auto"(用 FACTOR_DIRECTIONS 字典)
Returns:
方向调整后的因子(同向 = 越大越好)
"""
if factor.empty:
return factor.copy()
if direction == "auto":
# 用因子名匹配(假设 Series.name 是因子名)
name = factor.name
direction = FACTOR_DIRECTIONS.get(str(name), "forward")
if direction == "reverse":
return -factor
return factor
def cross_sectional_rank_with_direction(
df: pd.DataFrame,
factor_col: str,
direction: str = "auto",
group_col: str | None = None,
) -> pd.Series:
"""带方向感知的截面 rank(reverse 因子反转后做 rank,forward 因子直接 rank)。
Args:
df: DataFrame
factor_col: 因子列名
direction: "forward" / "reverse" / "auto"(用 FACTOR_DIRECTIONS)
group_col: 分组列(None = 全截面)
Returns:
pd.Series(百分位排名 [0, 1],越大越优)
"""
if df.empty or factor_col not in df.columns:
return pd.Series(dtype=float)
factor = df[factor_col]
direction = (
"forward" if direction == "auto" and factor.name not in FACTOR_DIRECTIONS else direction
)
direction = (
FACTOR_DIRECTIONS.get(str(factor.name), "forward") if direction == "auto" else direction
)
# pandas rank(pct=True, ascending=X) 行为:
# ascending=True → 最小值 rank=1,pct=1.0(最大)
# ascending=False → 最大值 rank=1,pct=1.0/4=0.25(最小) # pct 是 rank/n 不是 max=1
# 因此:
# forward (高优) → ascending=True(让高值排后 → 高 pct)
# reverse (低优) → ascending=False(让低值排后 → 高 pct)
if direction == "reverse":
ascending = False # 让低值排后 → 高 pct
else:
ascending = True # 让高值排后 → 高 pct
if group_col and group_col in df.columns:
return df.groupby(group_col)[factor_col].rank(pct=True, ascending=ascending, method="min")
return df[factor_col].rank(pct=True, ascending=ascending, method="min")
+909
View File
@@ -0,0 +1,909 @@
"""技术指标 —— 通用 RSI / 滚动百分位 / 周频衍生净值。
源项目出现重复:
- cal_RSI_n(hot_indicator_stockfund/func_else.py)
- cal_df_rolling_percent(hot_indicator_stockfund/func_else.py)
- gnrt_derive_nav(hot_indicator_stockfund/func_else.py)
提取到共享层后,所有子项目统一调用。
"""
from __future__ import annotations
import numpy as np
import pandas as pd
# ── RSI 指标 ──────────────────────────────────
def rsi(df: pd.DataFrame, n: int = 30) -> pd.DataFrame:
"""计算过去 n 天的 RSI 指标(区间 [0, 100])。
RSI = A / (A + B)
A = 上升天数点数之和, B = 下降天数点数绝对值之和
对应源 `cal_RSI_n`。
"""
delta = df.diff()
df_z = delta.clip(lower=0)
df_f = delta.clip(upper=0)
rsi_df = pd.DataFrame(index=df.index, columns=df.columns, dtype=float)
for date in df.index:
start = date - pd.Timedelta(days=n)
if start < df.index[0]:
continue
a = df_z.loc[start:date].sum()
b = abs(df_f.loc[start:date].sum())
denom = a + b
rsi_df.loc[date] = (a / denom.replace(0, np.nan)).values
return rsi_df.dropna(how="all")
# ── 滚动百分位 ──────────────────────────────
def rolling_percentile(df0: pd.DataFrame, back_window: int = 365) -> pd.DataFrame:
"""计算滚动窗口内的历史百分位排名(区间 [0, 1])。
对每个日期,统计过去 back_window 天内小于当前值的样本占比。
对应源 `cal_df_rolling_percent`。
"""
result = pd.DataFrame()
for col in df0.columns:
s = df0[col].dropna()
percentiles: dict[pd.Timestamp, float] = {}
for date in s.index:
start = date - pd.Timedelta(days=back_window)
if start < s.index[0]:
continue
window = s.loc[start:date]
x = s.loc[date]
a = (window < x).sum()
b = (window >= x).sum()
percentiles[date] = a / (a + b) if (a + b) > 0 else 0.5
result[col] = pd.Series(percentiles)
return result
def global_percentile(df0: pd.DataFrame) -> pd.DataFrame:
"""计算全局窗口的历史百分位排名(区间 [0, 1])。
用到当前日期为止的所有历史数据。
对应源 `cal_df_whole_percent`。
"""
result = pd.DataFrame(index=df0.index, columns=df0.columns, dtype=float)
for col in df0.columns:
s = df0[col].dropna()
for i in range(1, len(s)):
window = s.iloc[:i]
x = s.iloc[i]
a = (window < x).sum()
b = (window >= x).sum()
result.loc[s.index[i], col] = a / (a + b) if (a + b) > 0 else 0.5
return result.dropna(how="all")
# ── 周频衍生净值 ──────────────────────────────
def derive_weekly(nav_df0: pd.DataFrame) -> pd.DataFrame:
"""将日频净值转为周频(取每周最后一天)。
对应源 `gnrt_derive_nav`。
"""
nav_df = nav_df0.copy()
nav_df["upcoming_sunday"] = nav_df.index.to_series().apply(
lambda x: pd.Timestamp(x) + pd.Timedelta(days=(6 - x.weekday()))
)
res = nav_df.groupby("upcoming_sunday").agg("last")
res.index = pd.Index([str(d)[:10] for d in res.index], name="TradingDay")
return res
# ── 市场择时(按策略热度过滤)────────────────
def market_timing(
price_col: pd.Series,
timing_signal: pd.Series,
n_grade: int = 50,
) -> pd.DataFrame:
"""按 timing_signal (策略热度等) 过滤后构建择时净值。
当 timing_signal >= n_grade 时做多,否则空仓。
对应源 `market_time_df`。
"""
df = pd.DataFrame(
{
"价格": price_col,
"择时信号": timing_signal,
}
)
df["收益率"] = df["价格"].pct_change()
df["择时收益率"] = df.loc[df["择时信号"] >= n_grade, "收益率"]
df["理想择时净值"] = (df["择时收益率"].fillna(0) + 1).cumprod()
return df
# ── 截面分位数分组(场景构造基础)────────────
def quintile_group(
values: pd.Series,
n_groups: int = 5,
) -> pd.Series:
"""按分位数分 n 组([1, n_groups])。左闭右开区间。"""
arr = values.values
thresholds = (
[-1e6] + [np.percentile(arr, i) for i in np.linspace(0, 100, n_groups + 1)[1:-1]] + [1e6]
)
labels = list(range(1, n_groups + 1))
return pd.cut(values, bins=thresholds, right=False, labels=labels)
# ── v1.2.0 扩充:技术指标(MACD / KDJ / 布林 / ATR / ADX / CCI / OBV / SAR / TRIX 等) ──
#
# 所有指标采用纯 pandas 实现,零新依赖。
# 接受 Series(单标的)或 DataFrame(多标的 / 时序)。
# 命名风格参考 TA-Lib 与同花顺 / 通达信。
#
def sma(series: pd.Series, n: int) -> pd.Series:
"""简单移动平均(Simple Moving Average)。"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
return series.rolling(n, min_periods=n).mean()
def ema(series: pd.Series, n: int) -> pd.Series:
"""指数移动平均(Exponential Moving Average)。"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
return series.ewm(span=n, adjust=False).mean()
def macd(
close: pd.Series,
fast: int = 12,
slow: int = 26,
signal: int = 9,
) -> pd.DataFrame:
"""MACD(Moving Average Convergence Divergence)。
返回 DataFrame: dif / dea / macd 三列。
"""
if fast <= 0 or slow <= 0 or signal <= 0:
raise ValueError("fast/slow/signal must be positive")
ema_fast = ema(close, fast)
ema_slow = ema(close, slow)
dif = ema_fast - ema_slow
dea = ema(dif, signal)
macd_hist = (dif - dea) * 2.0
return pd.DataFrame({"dif": dif, "dea": dea, "macd": macd_hist})
def bollinger(
close: pd.Series,
n: int = 20,
num_std: float = 2.0,
) -> pd.DataFrame:
"""布林带(Bollinger Bands)。
返回 DataFrame: mid / upper / lower 三列。
"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
mid = sma(close, n)
std = close.rolling(n, min_periods=n).std()
upper = mid + num_std * std
lower = mid - num_std * std
return pd.DataFrame({"mid": mid, "upper": upper, "lower": lower})
def kdj(
high: pd.Series,
low: pd.Series,
close: pd.Series,
n: int = 9,
k_period: int = 3,
d_period: int = 3,
) -> pd.DataFrame:
"""KDJ 随机指标。
返回 DataFrame: k / d / j 三列。
"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
low_n = low.rolling(n, min_periods=n).min()
high_n = high.rolling(n, min_periods=n).max()
rsv = (close - low_n) / (high_n - low_n).replace(0, 1e-9) * 100.0
k = rsv.ewm(alpha=1.0 / k_period, adjust=False).mean()
d = k.ewm(alpha=1.0 / d_period, adjust=False).mean()
j = 3 * k - 2 * d
return pd.DataFrame({"k": k, "d": d, "j": j})
def atr(
high: pd.Series,
low: pd.Series,
close: pd.Series,
n: int = 14,
) -> pd.Series:
"""ATR(Average True Range)平均真实波幅。"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
prev_close = close.shift(1)
tr = pd.concat(
[
high - low,
(high - prev_close).abs(),
(low - prev_close).abs(),
],
axis=1,
).max(axis=1)
return tr.rolling(n, min_periods=n).mean()
def adx(
high: pd.Series,
low: pd.Series,
close: pd.Series,
n: int = 14,
) -> pd.DataFrame:
"""ADX(Average Directional Index)平均趋向指数。
返回 DataFrame: pdi / ndi / adx 三列。
"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
up_move = high.diff()
down_move = -low.diff()
plus_dm = ((up_move > down_move) & (up_move > 0)) * up_move
minus_dm = ((down_move > up_move) & (down_move > 0)) * down_move
tr = atr(high, low, close, n=1) # 单期 TR
atr_n = tr.rolling(n, min_periods=n).sum()
plus_di = 100 * plus_dm.rolling(n, min_periods=n).sum() / atr_n.replace(0, 1e-9)
minus_di = 100 * minus_dm.rolling(n, min_periods=n).sum() / atr_n.replace(0, 1e-9)
dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, 1e-9)
adx_n = dx.rolling(n, min_periods=n).mean()
return pd.DataFrame({"pdi": plus_di, "ndi": minus_di, "adx": adx_n})
def cci(
high: pd.Series,
low: pd.Series,
close: pd.Series,
n: int = 20,
) -> pd.Series:
"""CCI(Commodity Channel Index)商品通道指标。"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
tp = (high + low + close) / 3.0
ma = tp.rolling(n, min_periods=n).mean()
md = tp.rolling(n, min_periods=n).apply(
lambda x: float(np.mean(np.abs(x - x.mean()))),
raw=True,
)
return (tp - ma) / (0.015 * md).replace(0, 1e-9)
def obv(close: pd.Series, volume: pd.Series) -> pd.Series:
"""OBV(On Balance Volume)能量潮指标。"""
direction = np.sign(close.diff()).fillna(0)
return (direction * volume).cumsum()
def mfi(
high: pd.Series,
low: pd.Series,
close: pd.Series,
volume: pd.Series,
n: int = 14,
) -> pd.Series:
"""MFI(Money Flow Index)资金流量指标。"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
tp = (high + low + close) / 3.0
raw_money_flow = tp * volume
direction = np.sign(tp.diff()).fillna(0)
positive = (direction > 0) * raw_money_flow
negative = (direction < 0) * raw_money_flow
pos_sum = positive.rolling(n, min_periods=n).sum()
neg_sum = negative.rolling(n, min_periods=n).sum()
mf_ratio = pos_sum / neg_sum.replace(0, 1e-9)
return 100 - (100 / (1 + mf_ratio))
def roc(series: pd.Series, n: int = 12) -> pd.Series:
"""ROC(Rate of Change)变动率指标。"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
prev = series.shift(n)
return (series - prev) / prev.replace(0, 1e-9) * 100
def momentum(series: pd.Series, n: int = 10) -> pd.Series:
"""动量指标 = close - close.shift(n)。"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
return series - series.shift(n)
def trix(close: pd.Series, n: int = 15) -> pd.Series:
"""TRIX 三重指数平滑指标。"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
e1 = ema(close, n)
e2 = ema(e1, n)
e3 = ema(e2, n)
prev = e3.shift(1)
return (e3 - prev) / prev.replace(0, 1e-9) * 100
def wvad(close: pd.Series, high: pd.Series, low: pd.Series, volume: pd.Series) -> pd.Series:
"""WVAD(Williams Variable Accumulation/Distribution)威廉变异离散量。
简化版:(close - open) / (high - low) × volume,open 缺省时用 close.shift(1)。
"""
open_proxy = close.shift(1).fillna(close.iloc[0])
hl_diff = (high - low).replace(0, 1e-9)
return ((close - open_proxy) / hl_diff * volume).cumsum()
def sar(high: pd.Series, low: pd.Series, af_step: float = 0.02, af_max: float = 0.2) -> pd.Series:
"""抛物线 SAR(Parabolic Stop And Reverse)—— 简化版。
仅返回 SAR 值(不含方向);完整版需要额外的 trend 状态机。
"""
sar_arr = np.full(len(high), np.nan)
if len(high) < 2:
return pd.Series(sar_arr, index=high.index)
# 极简实现:取窗口内最低价作为 SAR(实际应迭代计算)
sar_arr[0] = low.iloc[0]
for i in range(1, len(high)):
sar_arr[i] = low.iloc[max(0, i - 1) : i + 1].min()
return pd.Series(sar_arr, index=high.index)
def stddev_pct(series: pd.Series, n: int) -> pd.Series:
"""滚动变异系数(CV):std / mean × 100%。"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
mean = series.rolling(n, min_periods=n).mean()
std = series.rolling(n, min_periods=n).std()
return std / mean.replace(0, 1e-9) * 100
def williams_r(high: pd.Series, low: pd.Series, close: pd.Series, n: int = 14) -> pd.Series:
"""Williams %R 摆动指标(区间 [-100, 0])。"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
high_n = high.rolling(n, min_periods=n).max()
low_n = low.rolling(n, min_periods=n).min()
hl_diff = (high_n - low_n).replace(0, 1e-9)
return (high_n - close) / hl_diff * -100
def cmo(series: pd.Series, n: int = 14) -> pd.Series:
"""CMO(Chande Momentum Oscillator)钱德动量摆动指标(区间 [-100, 100])。"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
diff = series.diff()
up_sum = diff.clip(lower=0).rolling(n, min_periods=n).sum()
down_sum = (-diff.clip(upper=0)).rolling(n, min_periods=n).sum()
return (up_sum - down_sum) / (up_sum + down_sum).replace(0, 1e-9) * 100
def dmi(high: pd.Series, low: pd.Series, n: int = 14) -> pd.DataFrame:
"""DMI(Directional Movement Index)趋向指标 = ADX 的简化封装。"""
return adx(high, low, high.rename("close_proxy"), n=n)
def bbands_width(close: pd.Series, n: int = 20, num_std: float = 2.0) -> pd.Series:
"""布林带宽度(upper - lower)/ mid。"""
bb = bollinger(close, n, num_std)
return ((bb["upper"] - bb["lower"]) / bb["mid"].replace(0, 1e-9)) * 100
def bbands_pct_b(close: pd.Series, n: int = 20, num_std: float = 2.0) -> pd.Series:
"""布林带 %b(价格在带中的位置)。"""
bb = bollinger(close, n, num_std)
width = (bb["upper"] - bb["lower"]).replace(0, 1e-9)
return (close - bb["lower"]) / width
def typical_price(high: pd.Series, low: pd.Series, close: pd.Series) -> pd.Series:
"""典型价格 = (high + low + close) / 3。"""
return (high + low + close) / 3.0
def weighted_close(high: pd.Series, low: pd.Series, close: pd.Series) -> pd.Series:
"""加权收盘价 = (high + low + close * 2) / 4。"""
return (high + low + close * 2) / 4.0
def median_price(high: pd.Series, low: pd.Series) -> pd.Series:
"""中位价 = (high + low) / 2。"""
return (high + low) / 2.0
def hilo_indicator(high: pd.Series, low: pd.Series, close: pd.Series) -> pd.Series:
"""高低价差占 close 的百分比。"""
return ((high - low) / close.replace(0, 1e-9)) * 100
def aroon(high: pd.Series, low: pd.Series, n: int = 25) -> pd.DataFrame:
"""Aroon 指标。
返回 DataFrame: aroon_up / aroon_down 两列。
"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
def _idx_since_max(s: pd.Series) -> float:
return float(n - 1 - s.argmax())
def _idx_since_min(s: pd.Series) -> float:
return float(n - 1 - s.argmin())
aroon_up = high.rolling(n, min_periods=n).apply(_idx_since_max, raw=True) / n * 100
aroon_down = low.rolling(n, min_periods=n).apply(_idx_since_min, raw=True) / n * 100
return pd.DataFrame({"aroon_up": aroon_up, "aroon_down": aroon_down})
# ── v1.2.0 高级指标扩展 ──────────────────────────────
def vwap(high: pd.Series, low: pd.Series, close: pd.Series, volume: pd.Series) -> pd.Series:
"""VWAP(Volume Weighted Average Price)累积。
VWAP = cumsum((H+L+C)/3 * V) / cumsum(V)
Args:
high, low, close: 行情
volume: 成交量
Returns:
VWAP 时间序列
"""
tp = (high + low + close) / 3.0
cumulative_tp_vol = (tp * volume).cumsum()
cumulative_vol = volume.cumsum().replace(0, 1e-9)
return cumulative_tp_vol / cumulative_vol
def ichimoku(
high: pd.Series,
low: pd.Series,
close: pd.Series,
tenkan_n: int = 9,
kijun_n: int = 26,
senkou_b_n: int = 52,
) -> pd.DataFrame:
"""一目均衡表(Ichimoku Kinko Hyo)。
返回 DataFrame: tenkan / kijun / senkou_a / senkou_b / chikou / cloud_top / cloud_bottom
"""
tenkan = (high.rolling(tenkan_n).max() + low.rolling(tenkan_n).min()) / 2
kijun = (high.rolling(kijun_n).max() + low.rolling(kijun_n).min()) / 2
senkou_a = ((tenkan + kijun) / 2).shift(kijun_n)
senkou_b = ((high.rolling(senkou_b_n).max() + low.rolling(senkou_b_n).min()) / 2).shift(kijun_n)
chikou = close.shift(-kijun_n)
cloud_top = pd.concat([senkou_a, senkou_b], axis=1).max(axis=1)
cloud_bottom = pd.concat([senkou_a, senkou_b], axis=1).min(axis=1)
return pd.DataFrame(
{
"tenkan": tenkan,
"kijun": kijun,
"senkou_a": senkou_a,
"senkou_b": senkou_b,
"chikou": chikou,
"cloud_top": cloud_top,
"cloud_bottom": cloud_bottom,
}
)
def parabolic_sar(
high: pd.Series,
low: pd.Series,
af_step: float = 0.02,
af_max: float = 0.2,
) -> pd.Series:
"""抛物线 SAR(Parabolic Stop and Reverse)—— 完整版。
与之前 sar() 简化版的区别:本函数实现完整的状态机(trend / af / ep)。
"""
n = len(high)
if n < 2:
return pd.Series([np.nan] * n, index=high.index)
sar_arr = np.zeros(n)
trend = np.ones(n) # 1=up, -1=down
af = np.zeros(n)
ep = np.zeros(n)
# 初始化
sar_arr[0] = low.iloc[0]
ep[0] = high.iloc[0]
af[0] = af_step
for i in range(1, n):
prev_sar = sar_arr[i - 1]
prev_trend = trend[i - 1]
prev_af = af[i - 1]
prev_ep = ep[i - 1]
cur_high = high.iloc[i]
cur_low = low.iloc[i]
if prev_trend == 1: # uptrend
new_sar = prev_sar + prev_af * (prev_ep - prev_sar)
new_sar = (
min(new_sar, low.iloc[i - 1], low.iloc[max(0, i - 2)])
if i >= 2
else low.iloc[i - 1]
)
if cur_low < new_sar:
# 反转
trend[i] = -1
sar_arr[i] = prev_ep
ep[i] = cur_low
af[i] = af_step
else:
trend[i] = 1
sar_arr[i] = new_sar
ep[i] = max(prev_ep, cur_high)
af[i] = prev_af + af_step if cur_high > prev_ep else prev_af
af[i] = min(af[i], af_max)
else: # downtrend
new_sar = prev_sar + prev_af * (prev_ep - prev_sar)
new_sar = (
max(new_sar, high.iloc[i - 1], high.iloc[max(0, i - 2)])
if i >= 2
else high.iloc[i - 1]
)
if cur_high > new_sar:
# 反转
trend[i] = 1
sar_arr[i] = prev_ep
ep[i] = cur_high
af[i] = af_step
else:
trend[i] = -1
sar_arr[i] = new_sar
ep[i] = min(prev_ep, cur_low)
af[i] = prev_af + af_step if cur_low < prev_ep else prev_af
af[i] = min(af[i], af_max)
return pd.Series(sar_arr, index=high.index)
def ultimate_oscillator(
high: pd.Series,
low: pd.Series,
close: pd.Series,
n1: int = 7,
n2: int = 14,
n3: int = 28,
) -> pd.Series:
"""Ultimate Oscillator(终极摆动指标)。"""
prev_close = close.shift(1)
bp = close - pd.concat([low, prev_close], axis=1).min(axis=1)
tr = pd.concat(
[
high - low,
(high - prev_close).abs(),
(low - prev_close).abs(),
],
axis=1,
).max(axis=1)
avg1 = bp.rolling(n1).sum() / tr.rolling(n1).sum().replace(0, 1e-9)
avg2 = bp.rolling(n2).sum() / tr.rolling(n2).sum().replace(0, 1e-9)
avg3 = bp.rolling(n3).sum() / tr.rolling(n3).sum().replace(0, 1e-9)
return 100 * (4 * avg1 + 2 * avg2 + avg3) / 7
def aroon_oscillator(
high: pd.Series,
low: pd.Series,
n: int = 25,
) -> pd.Series:
"""Aroon Oscillator = Aroon Up - Aroon Down(区间 [-100, +100])。"""
def _periods_since(s: pd.Series) -> float:
return float(n - 1 - s.argmax())
aroon_up = high.rolling(n, min_periods=n).apply(_periods_since, raw=True) / n * 100
aroon_down = (
low.rolling(n, min_periods=n).apply(
lambda s: float(n - 1 - s.argmin()),
raw=True,
)
/ n
* 100
)
return aroon_up - aroon_down
def ttm_squeeze(
close: pd.Series,
high: pd.Series,
low: pd.Series,
bb_n: int = 20,
bb_std: float = 2.0,
kc_n: int = 20,
kc_mult: float = 1.5,
) -> pd.DataFrame:
"""TTM Squeeze 指标(Bollinger Bands inside Keltner Channels)。
返回 DataFrame: squeeze_on (bool), momentum (hist)
"""
bb_mid = sma(close, bb_n)
bb_std_val = close.rolling(bb_n, min_periods=bb_n).std()
bb_upper = bb_mid + bb_std * bb_std_val
bb_lower = bb_mid - bb_std * bb_std_val
kc_mid = sma(close, kc_n)
tr = pd.concat(
[
high - low,
(high - close.shift(1)).abs(),
(low - close.shift(1)).abs(),
],
axis=1,
).max(axis=1)
kc_range = tr.rolling(kc_n, min_periods=kc_n).mean() * kc_mult
kc_upper = kc_mid + kc_range
kc_lower = kc_mid - kc_range
squeeze_on = (bb_lower >= kc_lower) & (bb_upper <= kc_upper)
momentum = close - bb_mid
return pd.DataFrame({"squeeze_on": squeeze_on, "momentum": momentum})
def hurst_exponent(series: pd.Series, max_lag: int = 20) -> float:
"""Hurst 指数(简化版:衡量时间序列长期记忆性)。
H < 0.5: 均值回复
H = 0.5: 随机游走
H > 0.5: 趋势延续
Args:
series: 价格时间序列
max_lag: 最大滞后阶数
Returns:
Hurst 指数估算
"""
lags = range(2, min(max_lag, len(series) // 2))
tau = []
for lag in lags:
diff = series.diff(lag).dropna()
if len(diff) > 0:
tau.append(diff.std())
if len(tau) < 2:
return 0.5
log_lags = np.log(list(lags))
log_tau = np.log(tau)
# polyfit: log(tau) = H * log(lag) + const
poly = np.polyfit(log_lags, log_tau, 1)
return float(poly[0]) # slope IS Hurst 直接
def vpt(close: pd.Series, volume: pd.Series) -> pd.Series:
"""VPT(Volume Price Trend)量价趋势指标。
VPT = cumsum(volume * (close - prev_close) / prev_close)
"""
pct_change = close.pct_change().fillna(0)
return (volume * pct_change).cumsum()
def chaikin_money_flow(
high: pd.Series,
low: pd.Series,
close: pd.Series,
volume: pd.Series,
n: int = 20,
) -> pd.Series:
"""Chaikin Money Flow(蔡金资金流量)。
CMF = sum(MFV, n) / sum(volume, n)
MFV = ((close - low) - (high - close)) / (high - low) * volume
"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
hl_diff = (high - low).replace(0, 1e-9)
mfv = ((close - low) - (high - close)) / hl_diff * volume
return mfv.rolling(n, min_periods=n).sum() / volume.rolling(n, min_periods=n).sum().replace(
0, 1e-9
)
def ease_of_movement(
high: pd.Series,
low: pd.Series,
volume: pd.Series,
n: int = 14,
) -> pd.Series:
"""Ease of Movement(EMV)简易移动指标。
EMV = (H+L)/2 距离中点变化 / volume 比例
"""
mid = (high + low) / 2.0
mid_change = mid.diff()
box_ratio = (volume / 1_000_000) / (high - low).replace(0, 1e-9)
emv = mid_change / box_ratio
return emv.rolling(n, min_periods=n).mean()
def dpo(close: pd.Series, n: int = 20) -> pd.Series:
"""DPO(Detrended Price Oscillator)去趋势价格摆动指标。
DPO = close - SMA(close, n).shift(n / 2 + 1)
"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
shift = n // 2 + 1
return close - sma(close, n).shift(shift)
# ── v1.2.0 第 3 批:补充指标(达 50+ 目标) ─────────────────
def rsi_series(close: pd.Series, n: int = 14) -> pd.Series:
"""RSI 指标(Series 版,Wilder 平滑)。
与 rsi(df) 不同:接受单列 Series,用 Wilder 平滑(EMA 系数 1/n)。
"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
delta = close.diff()
gain = delta.clip(lower=0)
loss = -delta.clip(upper=0)
avg_gain = gain.ewm(alpha=1.0 / n, adjust=False).mean()
avg_loss = loss.ewm(alpha=1.0 / n, adjust=False).mean()
rs = avg_gain / avg_loss.replace(0, 1e-9)
return 100 - 100 / (1 + rs)
def stochastic(
high: pd.Series,
low: pd.Series,
close: pd.Series,
n: int = 14,
smooth_k: int = 3,
smooth_d: int = 3,
) -> pd.DataFrame:
"""Stochastic Oscillator(随机摆动 %K / %D / %J)。
返回 DataFrame: k / d / j
"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
low_n = low.rolling(n, min_periods=n).min()
high_n = high.rolling(n, min_periods=n).max()
k_raw = (close - low_n) / (high_n - low_n).replace(0, 1e-9) * 100
k = k_raw.rolling(smooth_k, min_periods=smooth_k).mean()
d = k.rolling(smooth_d, min_periods=smooth_d).mean()
j = 3 * k - 2 * d
return pd.DataFrame({"k": k, "d": d, "j": j})
def aroon_up(
high: pd.Series,
low: pd.Series,
n: int = 25,
) -> pd.Series:
"""Aroon Up(高点距离最近的新高多少期)。"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
return high.rolling(n, min_periods=n).apply(
lambda s: float(n - 1 - s.argmax()) / n * 100,
raw=True,
)
def aroon_down(
high: pd.Series,
low: pd.Series,
n: int = 25,
) -> pd.Series:
"""Aroon Down(低点距离最近的新低多少期)。"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
return low.rolling(n, min_periods=n).apply(
lambda s: float(n - 1 - s.argmin()) / n * 100,
raw=True,
)
def bollinger_squeeze(
close: pd.Series,
n: int = 20,
num_std: float = 2.0,
) -> pd.Series:
"""布林带收窄检测:带宽低于历史中位数 → squeeze。
返回 bool Series(True = 收窄 / 即将突破)。
"""
bb = bollinger(close, n, num_std)
width = (bb["upper"] - bb["lower"]) / bb["mid"].replace(0, 1e-9)
median_width = width.rolling(100, min_periods=50).median()
return width < median_width
def on_balance_volume(
close: pd.Series,
volume: pd.Series,
) -> pd.Series:
"""OBV 能量潮(别名,保证 API 命名统一)。"""
direction = np.sign(close.diff()).fillna(0)
return (direction * volume).cumsum()
def mass_index(
high: pd.Series,
low: pd.Series,
n: int = 25,
) -> pd.Series:
"""Mass Index(质量指数):衡量价格反转点。"""
if n <= 0:
raise ValueError(f"n must be positive, got {n}")
hl = high - low
ema9 = hl.ewm(span=9, adjust=False).mean()
ema9_ema9 = ema9.ewm(span=9, adjust=False).mean()
ratio = ema9 / ema9_ema9.replace(0, 1e-9)
return ratio.rolling(n, min_periods=n).sum()
__all__ = [
"rsi",
"rolling_percentile",
"global_percentile",
"derive_weekly",
"market_timing",
"quintile_group",
"sma",
"ema",
"macd",
"bollinger",
"kdj",
"atr",
"adx",
"cci",
"obv",
"mfi",
"roc",
"momentum",
"trix",
"wvad",
"sar",
"stddev_pct",
"williams_r",
"cmo",
"dmi",
"bbands_width",
"bbands_pct_b",
"typical_price",
"weighted_close",
"median_price",
"hilo_indicator",
"aroon",
# v1.2.0 高级指标扩展
"vwap",
"ichimoku",
"parabolic_sar",
"ultimate_oscillator",
"aroon_oscillator",
"ttm_squeeze",
"hurst_exponent",
"vpt",
"chaikin_money_flow",
"ease_of_movement",
"dpo",
# v1.2.0 第 3 批(达 50+)
"rsi_series",
"stochastic",
"aroon_up",
"aroon_down",
"bollinger_squeeze",
"on_balance_volume",
"mass_index",
]
+108
View File
@@ -0,0 +1,108 @@
"""统一日志 —— 替代源项目散落的 `print` / `LOG()` / `logging` 三套。
设计:
- **单例 logger**:每个模块用 `logger = logging.get_logger(__name__)`
- **结构化**:可输出 JSON 行(生产)或可读文本(开发),环境变量切
- **去重**:同名 logger 不重复挂 handler
- **不依赖 loguru**(虽然 pyproject 列了,但标准库 logging 够用,零额外依赖)
## 用法
```python
from quant_engine.logging import get_logger
logger = get_logger(__name__)
logger.info("开始计算", extra={"factor": "momentum_12_1", "date": "20240101"})
```
"""
from __future__ import annotations
import json
import logging
import sys
from datetime import datetime
from typing import Any
from collections.abc import Mapping
# 全局配置
_CONFIGURED = False
class JsonFormatter(logging.Formatter):
"""JSON 格式(生产 / 容器)。"""
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"ts": datetime.fromtimestamp(record.created).isoformat(),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
# 透传 extra 字段
for key, val in record.__dict__.items():
if key not in {
"name",
"msg",
"args",
"levelname",
"levelno",
"pathname",
"filename",
"module",
"exc_info",
"exc_text",
"stack_info",
"lineno",
"funcName",
"created",
"msecs",
"relativeCreated",
"thread",
"threadName",
"processName",
"process",
"message",
"asctime",
"taskName",
}:
try:
json.dumps(val)
payload[key] = val
except (TypeError, ValueError):
payload[key] = str(val)
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False)
def _configure() -> None:
global _CONFIGURED
if _CONFIGURED:
return
root = logging.getLogger()
root.setLevel(logging.INFO)
# 清掉已有 handler(避免重复)
for h in list(root.handlers):
root.removeHandler(h)
handler = logging.StreamHandler(sys.stderr)
# 默认走可读格式;生产设 LOG_FORMAT=json 切 JSON
import os
if os.environ.get("LOG_FORMAT", "text") == "json":
handler.setFormatter(JsonFormatter())
else:
handler.setFormatter(
logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
root.addHandler(handler)
_CONFIGURED = True
def get_logger(name: str) -> logging.Logger:
"""拿一个 logger(自动配 root)。"""
_configure()
return logging.getLogger(name)
+139
View File
@@ -0,0 +1,139 @@
"""金融指标计算工具 —— 替代源项目散落的收益率 / 波动率 / 回撤手写代码。
源问题:
- 大量 `df.pct_change()` / `np.log()` 散落在各 proj 的 main.py
- 风险指标(最大回撤、Calmar、Sortino)各 proj 实现不一致
本模块:
- 标准化:年化收益率 / 波动率 / Sharpe / 最大回撤 / Calmar
- 输入:收益率序列(pd.Series,index=日期)
- 输出:float 或 dict
约定:
- `r` 是日简单收益率(不是 log return)
- 年化用 252 交易日(A 股)
- 所有指标都对空 / 零长度 Series 防御
## 字段名约定(v0.9.0)
- 保留 `ann_return` / `ann_volatility` / `max_drawdown`(与 `summary` 同名)
- **同时** 返回 alias 字段 `annual_yield` / `annual_sd` / `drawback`
(与 `quant_engine.perf_stats.perf_stats` 对齐)
- **v0.4.0 起将弃用 `annual_yield` 字段名**(与 perf_stats 一同),统一为 `ann_return`;
当前双字段共存保持向后兼容
"""
from __future__ import annotations
from collections.abc import Mapping
import numpy as np
import pandas as pd
TRADING_DAYS_PER_YEAR = 252
# ── 基础 ──────────────────────────────────────
def annualized_return(r: pd.Series) -> float:
"""年化简单收益率:`(1 + cum_return) ** (252 / n) - 1`。"""
r = _clean(r)
if len(r) < 2:
return 0.0
cum = (1 + r).prod()
n = len(r)
return float(cum ** (TRADING_DAYS_PER_YEAR / n) - 1)
def annualized_volatility(r: pd.Series) -> float:
"""年化波动率:`std(r) * sqrt(252)`。"""
r = _clean(r)
if len(r) < 2:
return 0.0
return float(r.std() * np.sqrt(TRADING_DAYS_PER_YEAR))
def sharpe_ratio(r: pd.Series, rf: float = 0.0) -> float:
"""Sharpe = (年化收益 - rf) / 年化波动。"""
r = _clean(r)
if len(r) < 2:
return 0.0
vol = annualized_volatility(r)
if vol == 0:
return 0.0
return (annualized_return(r) - rf) / vol
def max_drawdown(r: pd.Series) -> float:
"""最大回撤(负数)。例如 -0.2 表示最大亏 20%。"""
r = _clean(r)
if len(r) < 2:
return 0.0
nav = (1 + r).cumprod()
peak = nav.cummax()
drawdown = (nav - peak) / peak
return float(drawdown.min())
def calmar_ratio(r: pd.Series) -> float:
"""Calmar = 年化收益 / |最大回撤|。"""
r = _clean(r)
if len(r) < 2:
return 0.0
mdd = max_drawdown(r)
if mdd == 0:
return 0.0
return annualized_return(r) / abs(mdd)
def win_rate(r: pd.Series) -> float:
"""胜率 = 正收益天数 / 总天数。"""
r = _clean(r)
if len(r) == 0:
return 0.0
return float((r > 0).sum() / len(r))
# ── 汇总 ──────────────────────────────────────
def summary(r: pd.Series, rf: float = 0.0) -> Mapping[str, float]:
"""一站式指标汇总(用于报告输出)。
Returns:
dict keys:
- 主字段(保留): ann_return, ann_volatility, sharpe, max_drawdown,
calmar, win_rate, n_days
- perf_stats 对齐 alias: annual_yield, annual_sd, drawback
(与 quant_engine.perf_stats.perf_stats 同名;v0.4.0 起
annual_yield 将弃用,统一为 ann_return)
"""
r = _clean(r)
ann_ret = annualized_return(r)
ann_vol = annualized_volatility(r)
mdd = max_drawdown(r)
return {
# 主字段(保留,向后兼容)
"n_days": len(r),
"ann_return": ann_ret,
"ann_volatility": ann_vol,
"sharpe": sharpe_ratio(r, rf),
"max_drawdown": mdd,
"calmar": calmar_ratio(r),
"win_rate": win_rate(r),
# v0.9.0 加 alias 字段,与 perf_stats 字段名对齐
"annual_yield": ann_ret,
"annual_sd": ann_vol,
"drawback": mdd,
}
# ── 内部 ──────────────────────────────────────
def _clean(r: pd.Series) -> pd.Series:
"""去掉 NaN / inf,保留 0 收益。"""
if not isinstance(r, pd.Series):
raise TypeError(f"expected pd.Series, got {type(r).__name__}")
return r.replace([np.inf, -np.inf], np.nan).dropna()
+132
View File
@@ -0,0 +1,132 @@
"""FOF 绩效统计 (替代源项目 cores.calc_fund_roll_statis 的 .pyd 接口)。
替代源: cores.pyd (cp37 编译, Python 3.11+ 不可用)
提供的 AnnualYield / SD / Sharpe / DownSD / DrawBack / Sortino / Calmar 7 个指标
公式约定 (与源 cores 完全一致):
freq : 年化频率 (周=2, 日=252)
ret : 简单收益率序列
AnnualYield = (1 + mean(ret))^freq - 1
AnnualSD = std(ret) * sqrt(freq)
Sharpe = AnnualYield / AnnualSD
AnnualDownSD = std(ret[ret<0]) * sqrt(freq)
DrawBack = min((1+ret).cumprod() / (1+ret).cumprod().cummax() - 1) (负数)
Sortino = AnnualYield / AnnualDownSD
Calmar = AnnualYield / |DrawBack|
## 字段名约定(v0.9.0)
- 保留 `annual_yield` / `annual_sd` / `annual_down_sd` / `drawback`(与源 cores 同名)
- **同时** 返回 alias 字段 `ann_return` / `annual_volatility` / `max_drawdown`
(与 `quant_engine.metrics.summary` 对齐)
- **v0.4.0 起将弃用 `annual_yield` 字段名**,统一为 `ann_return`;
当前双字段共存保持向后兼容
"""
from __future__ import annotations
from collections.abc import Mapping
import numpy as np
from numpy.typing import NDArray
from typing import Any
import pandas as pd
def _as_series(ret: pd.Series | NDArray[Any]) -> pd.Series:
"""把 ndarray 标准化成 Series, 丢掉 NaN/inf。"""
if isinstance(ret, np.ndarray):
ret = pd.Series(ret.ravel())
elif not isinstance(ret, pd.Series):
ret = pd.Series(ret)
return ret.replace([np.inf, -np.inf], np.nan).dropna()
def perf_stats(ret: pd.Series | NDArray[Any], freq: int = 2) -> dict[str, float]:
"""一站式 7 指标计算 (替代 cores.calc_fund_roll_statis)。
Args:
ret : 简单收益率序列 (周频或日频)
freq: 年化频率, 周频=2, 日频=252
Returns:
dict keys:
- 源 cores 字段(保留): annual_yield, annual_sd, sharpe,
annual_down_sd, drawback, sortino, calmar
- metrics 对齐 alias: ann_return, annual_volatility, max_drawdown
(与 quant_engine.metrics.summary 同名;v0.4.0 起 annual_yield
将弃用,统一为 ann_return)
"""
r = _as_series(ret)
n = len(r)
out: dict[str, float] = {
# 源 cores 字段(保留,向后兼容)
"annual_yield": 0.0,
"annual_sd": 0.0,
"sharpe": 0.0,
"annual_down_sd": 0.0,
"drawback": 0.0,
"sortino": 0.0,
"calmar": 0.0,
# v0.9.0 加 alias 字段,与 metrics.summary 字段名对齐
"ann_return": 0.0,
"annual_volatility": 0.0,
"max_drawdown": 0.0,
}
if n < 2:
return out
mean_r = float(r.mean())
sd_r = float(r.std())
annual_yield = (1.0 + mean_r) ** freq - 1.0
annual_sd = sd_r * np.sqrt(freq)
sharpe = annual_yield / annual_sd if annual_sd > 0 else 0.0
downside = r[r < 0]
if len(downside) >= 2:
down_sd = float(downside.std()) * np.sqrt(freq)
else:
down_sd = 0.0
# Drawback: (1+r) 的 cumprod = 净值路径
nav = (1.0 + r).cumprod()
peak = nav.cummax()
drawback = float(((nav - peak) / peak).min())
sortino = annual_yield / down_sd if down_sd > 0 else 0.0
calmar = annual_yield / abs(drawback) if drawback != 0 else 0.0
out.update(
{
"annual_yield": float(annual_yield),
"annual_sd": float(annual_sd),
"sharpe": float(sharpe),
"annual_down_sd": float(down_sd),
"drawback": float(drawback),
"sortino": float(sortino),
"calmar": float(calmar),
# alias 字段(与 metrics.summary 同义)
"ann_return": float(annual_yield),
"annual_volatility": float(annual_sd),
"max_drawdown": float(drawback),
}
)
return out
def perf_stats_df(
ret: pd.Series | NDArray[Any],
freq: int = 2,
) -> pd.DataFrame:
"""返回单行 DataFrame (便于横向 concat)。"""
s = perf_stats(ret, freq=freq)
return pd.DataFrame([s])
def batch_perf_stats(
ret_dict: Mapping[str, pd.Series | NDArray[Any]],
freq: int = 2,
) -> pd.DataFrame:
"""批量: {name: ret} -> DataFrame (index=name, columns=7 指标 + 3 alias)。"""
rows = {name: perf_stats(r, freq=freq) for name, r in ret_dict.items()}
return pd.DataFrame.from_dict(rows, orient="index")
+151
View File
@@ -0,0 +1,151 @@
"""portfolio_decomp - 组合分解通用 L1 / 风险贡献 / 归因工具。
替代源项目散落的最小二乘/最小一乘/Brinson 归因手写代码。
包含:
- l1_rebalance_decompose: L1 残差最小化资产配置分解 (FOF 策略指数核心)
- risk_parity_weights: 风险平价权重
- mean_variance_weights: 均值方差 (最大夏普)
约定:
- 输入 numpy.ndarray (y 1D, X 2D)
- 硬约束: Σβ = 1, β >= 0
"""
from __future__ import annotations
from collections.abc import Sequence
import numpy as np
from numpy.typing import NDArray
from typing import Any
from scipy.optimize import minimize
def l1_rebalance_decompose(
y: NDArray[Any],
X: NDArray[Any],
bounds: Sequence[tuple[float, float]] | None = None,
) -> NDArray[Any]:
"""L1 残差最小化资产配置分解。
目标: min ||y - X·β||_1
约束: Σβ = 1, β >= 0
替代源项目 FOF_strategy_index 的 weight() 函数
与 cores.calc_fund_asset_ratio 的 L1 残差部分。
"""
y = np.asarray(y, dtype=float).ravel()
X = np.asarray(X, dtype=float)
n, k = X.shape
if y.size != n:
raise ValueError(f"y 与 X 行数不一致: y={y.size}, X={n}")
if bounds is None:
bounds = [(0.0, 1.0)] * k
if len(bounds) != k:
raise ValueError(f"bounds 长度 {len(bounds)} != 资产数 {k}")
def _objective(beta: NDArray[Any]) -> float:
residual = y - X @ beta
return float(np.abs(residual).sum())
constraints = ({"type": "eq", "fun": lambda b: b.sum() - 1.0},)
x0 = np.full(k, 1.0 / k)
result = minimize(
_objective,
x0=x0,
method="SLSQP",
bounds=bounds,
constraints=constraints,
options={"maxiter": 200, "ftol": 1e-8},
)
if not result.success:
# 退化到 L2 (保险路径)
def l2_obj(b: NDArray[Any]) -> float:
return float(np.power(y - X @ b, 2).sum())
result = minimize(
l2_obj,
x0=x0,
method="SLSQP",
bounds=bounds,
constraints=constraints,
options={"maxiter": 200, "ftol": 1e-8},
)
beta = np.clip(result.x, 0.0, 1.0)
s = beta.sum()
if s > 0:
beta = beta / s
else:
beta = x0
return beta # type: ignore[no-any-return]
def risk_parity_weights(cov: NDArray[Any]) -> NDArray[Any]:
"""风险平价权重: min Σ (w_i * (Σw)_i)^2 s.t. Σw = 1, w > 0。"""
cov = np.asarray(cov, dtype=float)
k = cov.shape[0]
def _risk_contrib(w: NDArray[Any]) -> NDArray[Any]:
port_var = w @ cov @ w
if port_var <= 0:
return np.zeros(k)
mrc = cov @ w
return np.asarray(w * mrc / port_var)
def _objective(w: NDArray[Any]) -> float:
rc = _risk_contrib(w)
target = 1.0 / k
return float(((rc - target) ** 2).sum() * 1e4)
constraints = ({"type": "eq", "fun": lambda w: w.sum() - 1.0},)
bounds = [(1e-4, 1.0)] * k
x0 = np.full(k, 1.0 / k)
result = minimize(
_objective,
x0=x0,
method="SLSQP",
bounds=bounds,
constraints=constraints,
options={"maxiter": 300, "ftol": 1e-9},
)
w = np.clip(result.x, 1e-4, 1.0)
return w / w.sum() # type: ignore[no-any-return]
def mean_variance_weights(
mu: NDArray[Any],
cov: NDArray[Any],
risk_aversion: float = 1.0,
) -> NDArray[Any]:
"""均值-方差权重: max μ'w - (λ/2)·w'Σw s.t. Σw = 1, w ≥ 0。"""
mu = np.asarray(mu, dtype=float).ravel()
cov = np.asarray(cov, dtype=float)
k = len(mu)
def _neg_util(w: NDArray[Any]) -> float:
return float(-(mu @ w - 0.5 * risk_aversion * w @ cov @ w))
constraints = ({"type": "eq", "fun": lambda w: w.sum() - 1.0},)
# **v0.35.1 修复 flaky**:用 (1e-4, 1.0) 而非 (0.0, 1.0) 边界,
# 避免 SLSQP 在退化输入下输出全零权重而测试断言 w[0] > w[1] 失败
bounds = [(1e-4, 1.0)] * k
x0 = np.full(k, 1.0 / k)
result = minimize(
_neg_util,
x0=x0,
method="SLSQP",
bounds=bounds,
constraints=constraints,
options={"maxiter": 500, "ftol": 1e-10},
)
w = np.clip(result.x, 1e-4, 1.0)
s = w.sum()
if s > 0:
w = w / s
else:
w = x0
return w # type: ignore[no-any-return]
+53
View File
@@ -0,0 +1,53 @@
"""风险指标 - 风险贡献 / 边际风险贡献 / 风险平价相关计算。
替代源项目 FOF_strategy_index 中 risk_contribution(weights, cov) 手写实现。
"""
from __future__ import annotations
import numpy as np
from numpy.typing import NDArray
from typing import Any
def risk_contribution(weights: NDArray[Any], cov: NDArray[Any]) -> NDArray[Any]:
"""风险贡献率 (RC_i): w_i * (Σw)_i / w'Σw。
公式: RC_i = w_i · (Σw)_i / (w^T Σ w)
- 数值上 Σ RC_i = 1
- 数值上 RC_i >= 0 (在 Σ 对称半正定条件下)
- 业务含义: 每类资产对组合整体波动的"贡献份额"
Args:
weights: 资产权重向量 (k,)
cov: 协方差矩阵 (k, k), 须对称半正定
Returns:
RC: 风险贡献向量 (k,), Σ=1
"""
w = np.asarray(weights, dtype=float).ravel()
cov = np.asarray(cov, dtype=float)
k = w.size
if cov.shape != (k, k):
raise ValueError(f"cov 形状 {cov.shape} 与 weights 长度 {k} 不匹配")
port_var = float(w @ cov @ w)
if port_var <= 0:
return np.full(k, 1.0 / k)
mrc = cov @ w
rc = w * mrc / port_var
return rc # type: ignore[no-any-return]
def marginal_risk_contribution(weights: NDArray[Any], cov: NDArray[Any]) -> NDArray[Any]:
"""边际风险贡献 (MRC_i): (Σw)_i。"""
w = np.asarray(weights, dtype=float).ravel()
cov = np.asarray(cov, dtype=float)
return cov @ w # type: ignore[no-any-return]
def component_var(weights: NDArray[Any], cov: NDArray[Any]) -> NDArray[Any]:
"""成分方差: w_i · (Σw)_i; 与 RC 的关系 RC_i = CV_i / w'Σw。"""
w = np.asarray(weights, dtype=float).ravel()
cov = np.asarray(cov, dtype=float)
return w * (cov @ w) # type: ignore[no-any-return]
+15
View File
@@ -0,0 +1,15 @@
"""测试根 conftest —— 让 `from quant_engine.X import ...` 在不装包时也能工作。
- pytest 默认不会把 `src/` 加进 sys.path
- 我们用 sys.path.insert 在 conftest 阶段把 src/ 加进去
- 这样 `from quant_engine.X import ...` 能找到 `src/quant_engine/X.py`
"""
from __future__ import annotations
import sys
from pathlib import Path
# 把 src/ 加进 sys.path(pytest 启动时先于 collection)
_SRC = Path(__file__).resolve().parent.parent / "src"
if str(_SRC) not in sys.path:
sys.path.insert(0, str(_SRC))
File diff suppressed because it is too large Load Diff
+321
View File
@@ -0,0 +1,321 @@
"""src/shared/data_adapter.py 单元测试(v1.2.0 数据对齐层)。"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from quant_engine.data_adapter import (
add_vwap_proxy,
apply_adj_factor,
load_qtdb_daily,
long_to_wide,
prepare_execution_inputs,
prepare_stock_series,
rename_tushare_columns,
wide_to_long,
)
@pytest.fixture
def tushare_long() -> pd.DataFrame:
"""模拟 qtdb_pro.hq_daily 长表(Tushare 原始命名)。"""
return pd.DataFrame(
{
"ts_code": ["000001.SZ", "000001.SZ", "600000.SH", "600000.SH"],
"trade_date": ["2024-01-01", "2024-01-02", "2024-01-01", "2024-01-02"],
"open": [10.0, 11.0, 20.0, 21.0],
"high": [10.5, 11.5, 20.5, 21.5],
"low": [9.8, 10.8, 19.8, 20.8],
"close": [10.2, 11.2, 20.2, 21.2],
"pre_close": [10.0, 10.2, 20.0, 20.2],
"pct_chg": [2.0, 9.8, 1.0, 4.95],
"vol": [1000.0, 1100.0, 2000.0, 2100.0],
"amount": [10000.0, 12000.0, 40000.0, 44000.0],
}
)
# ── long_to_wide / wide_to_long ──────────────────────────────
def test_long_to_wide_basic(tushare_long: pd.DataFrame) -> None:
"""长表 → 宽表:2 股票 × 2 日期。"""
renamed = rename_tushare_columns(tushare_long)
wide = long_to_wide(renamed, value_col="close")
assert wide.shape == (2, 2)
assert set(wide.columns) == {"000001.SZ", "600000.SH"}
assert wide.index.is_monotonic_increasing
# 值校验:000001.SZ 两天 close
assert wide.loc[wide.index[0], "000001.SZ"] == pytest.approx(10.2)
assert wide.loc[wide.index[1], "000001.SZ"] == pytest.approx(11.2)
def test_long_to_wide_empty() -> None:
"""空输入 → 空 DataFrame。"""
assert long_to_wide(pd.DataFrame()).empty
def test_long_to_wide_missing_col_raises(tushare_long: pd.DataFrame) -> None:
"""缺列应报错。"""
with pytest.raises(ValueError, match="缺少列"):
long_to_wide(tushare_long, value_col="not_exist")
def test_wide_to_long_roundtrip(tushare_long: pd.DataFrame) -> None:
"""wide → long → wide roundtrip 应一致。"""
renamed = rename_tushare_columns(tushare_long)
wide = long_to_wide(renamed, value_col="close")
long_back = wide_to_long(wide, value_name="close")
# 重建 wide 应与原一致(NaN 会被 dropna)
wide2 = long_to_wide(long_back, value_col="close")
pd.testing.assert_frame_equal(
wide,
wide2,
check_names=False,
)
def test_wide_to_long_columns(tushare_long: pd.DataFrame) -> None:
"""wide_to_long 输出三列。"""
renamed = rename_tushare_columns(tushare_long)
wide = long_to_wide(renamed, value_col="close")
long_df = wide_to_long(wide, value_name="close")
assert set(long_df.columns) == {"trade_date", "stock_code", "close"}
# ── rename_tushare_columns ──────────────────────────────
def test_rename_tushare_columns_basic(tushare_long: pd.DataFrame) -> None:
"""ts_code→stock_code, vol→volume。"""
renamed = rename_tushare_columns(tushare_long)
assert "stock_code" in renamed.columns
assert "volume" in renamed.columns
assert "ts_code" not in renamed.columns
assert "vol" not in renamed.columns
assert "close" in renamed.columns # 已一致,不变
def test_rename_tushare_columns_noop() -> None:
"""无 Tushare 列 → 原样。"""
df = pd.DataFrame({"a": [1], "b": [2]})
assert rename_tushare_columns(df).columns.tolist() == ["a", "b"]
# ── add_vwap_proxy ──────────────────────────────────────
def test_vwap_amount_vol(tushare_long: pd.DataFrame) -> None:
"""vwap = amount*10/volume(amount 千元,vol 手)。"""
renamed = rename_tushare_columns(tushare_long)
out = add_vwap_proxy(renamed, method="amount_vol")
assert "vwap" in out.columns
# 000001.SZ 第一天:amount=10000(千元), volume=1000(手)
# vwap = 10000 * 10 / 1000 = 100 元/股
first = out[out["stock_code"] == "000001.SZ"].iloc[0]
assert first["vwap"] == pytest.approx(100.0)
def test_vwap_typical(tushare_long: pd.DataFrame) -> None:
"""typical price 代理。"""
renamed = rename_tushare_columns(tushare_long)
out = add_vwap_proxy(renamed, method="typical")
first = out[out["stock_code"] == "000001.SZ"].iloc[0]
expected = (10.5 + 9.8 + 10.2) / 3
assert first["vwap"] == pytest.approx(expected)
def test_vwap_close_fallback(tushare_long: pd.DataFrame) -> None:
"""close 兜底。"""
renamed = rename_tushare_columns(tushare_long)
out = add_vwap_proxy(renamed, method="close")
first = out[out["stock_code"] == "000001.SZ"].iloc[0]
assert first["vwap"] == pytest.approx(10.2)
def test_vwap_insufficient_cols_raises() -> None:
"""列不足应报错。"""
df = pd.DataFrame({"a": [1]})
with pytest.raises(ValueError, match="列不足"):
add_vwap_proxy(df)
# ── apply_adj_factor ──────────────────────────────────────
def test_apply_adj_factor_qfq() -> None:
"""前复权:价格 × adj / 最新 adj。"""
daily = pd.DataFrame(
{
"stock_code": ["A", "A", "A"],
"trade_date": ["2024-01-01", "2024-01-02", "2024-01-03"],
"close": [10.0, 20.0, 20.0], # 1/2 除权(10→20 拆股?反向演示)
"open": [10.0, 20.0, 20.0],
"high": [11.0, 21.0, 21.0],
"low": [9.0, 19.0, 19.0],
}
)
adj = pd.DataFrame(
{
"stock_code": ["A", "A", "A"],
"trade_date": ["2024-01-01", "2024-01-02", "2024-01-03"],
"adj_factor": [2.0, 2.0, 1.0], # 最新 = 1.0
}
)
out = apply_adj_factor(daily, adj, mode="qfq")
# 1/1: 10 * 2/1 = 20;1/2: 20 * 2/1 = 40;1/3: 20 * 1/1 = 20
closes = out.set_index("trade_date")["close"]
assert closes["2024-01-01"] == pytest.approx(20.0)
assert closes["2024-01-02"] == pytest.approx(40.0)
assert closes["2024-01-03"] == pytest.approx(20.0)
def test_apply_adj_factor_hfq() -> None:
"""后复权:价格 × adj。"""
daily = pd.DataFrame(
{
"stock_code": ["A", "A"],
"trade_date": ["2024-01-01", "2024-01-02"],
"close": [10.0, 10.0],
}
)
adj = pd.DataFrame(
{
"stock_code": ["A", "A"],
"trade_date": ["2024-01-01", "2024-01-02"],
"adj_factor": [2.0, 4.0],
}
)
out = apply_adj_factor(daily, adj, mode="hfq")
closes = out.set_index("trade_date")["close"]
assert closes["2024-01-01"] == pytest.approx(20.0)
assert closes["2024-01-02"] == pytest.approx(40.0)
def test_apply_adj_factor_empty() -> None:
"""adj_factor 为空 → 返回原行情。"""
daily = pd.DataFrame({"stock_code": ["A"], "trade_date": ["2024-01-01"], "close": [10.0]})
out = apply_adj_factor(daily, pd.DataFrame())
assert out["close"].iloc[0] == pytest.approx(10.0)
def test_apply_adj_factor_missing_dates() -> None:
"""无因子日期 → 保持原值。"""
daily = pd.DataFrame(
{
"stock_code": ["A", "A"],
"trade_date": ["2024-01-01", "2024-01-02"],
"close": [10.0, 12.0],
}
)
adj = pd.DataFrame(
{
"stock_code": ["A"],
"trade_date": ["2024-01-01"],
"adj_factor": [1.0],
}
)
out = apply_adj_factor(daily, adj, mode="qfq")
closes = out.set_index("trade_date")["close"]
assert closes["2024-01-01"] == pytest.approx(10.0)
assert closes["2024-01-02"] == pytest.approx(12.0) # 无因子 → 原值
# ── prepare_stock_series ──────────────────────────────────
def test_prepare_stock_series_basic(tushare_long: pd.DataFrame) -> None:
"""单股提取 → Series dict。"""
renamed = rename_tushare_columns(tushare_long)
series_map = prepare_stock_series(renamed, "000001.SZ")
assert "close" in series_map
assert "open" in series_map
assert "high" in series_map
assert "low" in series_map
assert "volume" in series_map
assert "vwap" in series_map # 自动补
assert len(series_map["close"]) == 2
assert series_map["close"].iloc[0] == pytest.approx(10.2)
def test_prepare_stock_series_unknown_stock(tushare_long: pd.DataFrame) -> None:
"""未知股票 → 空 dict。"""
renamed = rename_tushare_columns(tushare_long)
assert prepare_stock_series(renamed, "999999.SZ") == {}
def test_prepare_stock_series_empty() -> None:
"""空输入 → 空 dict。"""
assert prepare_stock_series(pd.DataFrame(), "A") == {}
# ── prepare_execution_inputs ──────────────────────────────
def test_prepare_execution_inputs_basic(tushare_long: pd.DataFrame) -> None:
"""prices + volumes 宽表。"""
renamed = rename_tushare_columns(tushare_long)
prices, volumes = prepare_execution_inputs(renamed)
assert prices.shape == (2, 2)
assert volumes.shape == (2, 2)
# prices 值 = close
assert prices.iloc[0, 0] == pytest.approx(10.2)
# volumes 值 = volume
assert volumes.iloc[0, 0] == pytest.approx(1000.0)
def test_prepare_execution_inputs_no_volume() -> None:
"""无 volume 列 → volumes 全 1.0。"""
df = pd.DataFrame(
{
"stock_code": ["A"],
"trade_date": ["2024-01-01"],
"close": [10.0],
}
)
_prices, volumes = prepare_execution_inputs(df)
assert volumes.iloc[0, 0] == pytest.approx(1.0)
def test_prepare_execution_inputs_missing_close_raises() -> None:
"""缺 close 列应报错。"""
df = pd.DataFrame({"stock_code": ["A"], "trade_date": ["2024-01-01"]})
with pytest.raises(ValueError, match="缺 close"):
prepare_execution_inputs(df)
# ── 端到端:长表 → 适配 → alpha158 + execution ──────────────
def test_end_to_end_tushare_to_alpha158(tushare_long: pd.DataFrame) -> None:
"""真实链路:Tushare 长表 → 单股 Series → alpha158。"""
from quant_engine.alpha_factors import alpha_001, alpha_005
renamed = rename_tushare_columns(tushare_long)
series_map = prepare_stock_series(renamed, "000001.SZ")
# alpha_001: rank(ts_rank(close, 5))
r1 = alpha_001(series_map["close"])
assert isinstance(r1, pd.Series)
# alpha_005: correlation(close, volume, 10)
r5 = alpha_005(series_map["close"], series_map["volume"])
assert isinstance(r5, pd.Series)
def test_end_to_end_tushare_to_execution(tushare_long: pd.DataFrame) -> None:
"""真实链路:Tushare 长表 → execution 宽表 → 端到端 POC。"""
from quant_engine.execution import simulate_with_daily_data
renamed = rename_tushare_columns(tushare_long)
prices, _volumes = prepare_execution_inputs(renamed)
# 用较长的数据才能跑(2 天太短,先验证不崩 + 返回正确类型)
positions = simulate_with_daily_data(prices, initial_cash=1_000_000.0)
assert len(positions) == len(prices)
def test_load_qtdb_daily_offline() -> None:
"""无真实 ClickHouse 时返回空表(不崩)。"""
df = load_qtdb_daily(["000001.SZ"], "2024-01-01")
# 本环境无 .env → 连接失败 → 空表
assert isinstance(df, pd.DataFrame)
+780
View File
@@ -0,0 +1,780 @@
"""src/shared/execution.py 单元测试(v1.2.0 Phase 0 骨架)。"""
from __future__ import annotations
from dataclasses import FrozenInstanceError
import numpy as np
import pandas as pd
import pytest
from quant_engine.execution import (
ExecutionConfig,
ExecutionResult,
apply_bid_ask_spread,
apply_volume_constraint,
check_price_limit,
check_stop_loss_take_profit,
check_t1_settlement,
compute_realized_pnl,
run_end_to_end_poc,
simulate_execution,
simulate_multi_day,
simulate_with_daily_data,
total_costs,
total_turnover,
)
# ── ExecutionConfig 校验 ──────────────────────────────
def test_default_config():
"""默认配置:A 股典型值。"""
cfg = ExecutionConfig()
assert cfg.commission_bps == 3.0
assert cfg.stamp_tax_bps == 10.0
assert cfg.slippage_bps == 5.0
assert cfg.min_trade_amount == 100.0
def test_config_negative_commission_raises():
"""负佣金应报错。"""
with pytest.raises(ValueError, match="non-negative"):
ExecutionConfig(commission_bps=-1.0)
def test_config_negative_stamp_tax_raises():
"""负印花税应报错。"""
with pytest.raises(ValueError, match="non-negative"):
ExecutionConfig(stamp_tax_bps=-1.0)
def test_config_negative_slippage_raises():
"""负滑点应报错。"""
with pytest.raises(ValueError, match="non-negative"):
ExecutionConfig(slippage_bps=-1.0)
def test_config_negative_min_amount_raises():
"""负最小成交额应报错。"""
with pytest.raises(ValueError, match="non-negative"):
ExecutionConfig(min_trade_amount=-1.0)
def test_config_is_frozen():
"""dataclass(frozen=True) 不允许修改。"""
cfg = ExecutionConfig()
with pytest.raises(FrozenInstanceError):
cfg.commission_bps = 100.0 # type: ignore[misc]
# ── simulate_execution 基础 ──────────────────────────────
def test_simulate_execution_basic_buy():
"""买入:目标权重 0.5,组合 100 万 → 目标 50 万 → 实际成交(含滑点)> 50 万。"""
cfg = ExecutionConfig()
results = simulate_execution(
target_weights={"000001.SZ": 0.5},
portfolio_value=1_000_000.0,
config=cfg,
)
assert len(results) == 1
r = results[0]
assert r.stock_code == "000001.SZ"
assert r.target_value == 500_000.0
# 滑点 5 bps = 0.0005,500000 * 1.0005 = 500250
assert r.executed_value == pytest.approx(500_250.0)
# 佣金 3 bps,按 500250 算 = 150.075
assert r.commission == pytest.approx(500_250.0 * 3 / 10000)
# 买入无印花税
assert r.stamp_tax == 0.0
# 滑点成本 = executed_value - abs(target_value)
assert r.slippage_cost == pytest.approx(250.0)
# 净现金流 = -(executed_value + commission + stamp_tax)
assert r.net_cash_flow == pytest.approx(-(500_250.0 + 500_250.0 * 3 / 10000))
def test_simulate_execution_basic_sell():
"""卖出:目标权重 -0.3(减仓),组合 100 万 → 目标 -30 万。"""
cfg = ExecutionConfig()
results = simulate_execution(
target_weights={"600000.SH": -0.3},
portfolio_value=1_000_000.0,
config=cfg,
)
assert len(results) == 1
r = results[0]
assert r.stock_code == "600000.SH"
assert r.target_value == -300_000.0
# 滑点 5 bps = 0.0005,300000 * (1 - 0.0005) = 299850
assert r.executed_value == pytest.approx(299_850.0)
# 卖出有印花税 10 bps
assert r.stamp_tax == pytest.approx(299_850.0 * 10 / 10000)
# 净现金流 = executed_value - commission - stamp_tax
assert r.net_cash_flow == pytest.approx(
299_850.0 - 299_850.0 * 3 / 10000 - 299_850.0 * 10 / 10000,
)
def test_simulate_execution_skip_below_min():
"""成交金额低于 min_trade_amount → 跳过(不返回 ExecutionResult)。"""
cfg = ExecutionConfig(min_trade_amount=10_000.0)
results = simulate_execution(
target_weights={"000001.SZ": 0.005}, # 100万 * 0.005 = 5000 < 10000
portfolio_value=1_000_000.0,
config=cfg,
)
assert results == []
def test_simulate_execution_empty_weights():
"""空权重字典 → 空结果列表。"""
cfg = ExecutionConfig()
results = simulate_execution(
target_weights={},
portfolio_value=1_000_000.0,
config=cfg,
)
assert results == []
def test_simulate_execution_multiple_stocks():
"""多支股票:每支一个 ExecutionResult。"""
cfg = ExecutionConfig()
targets = {
"000001.SZ": 0.3,
"600000.SH": 0.2,
"000002.SZ": 0.5,
}
results = simulate_execution(
target_weights=targets,
portfolio_value=1_000_000.0,
config=cfg,
)
assert len(results) == 3
codes = {r.stock_code for r in results}
assert codes == {"000001.SZ", "600000.SH", "000002.SZ"}
def test_simulate_execution_default_config():
"""config=None → 使用默认 A 股配置。"""
results = simulate_execution(
target_weights={"000001.SZ": 0.5},
portfolio_value=1_000_000.0,
)
assert len(results) == 1
def test_simulate_execution_zero_costs_config():
"""零成本配置:executed_value == target_value。"""
cfg = ExecutionConfig(commission_bps=0, stamp_tax_bps=0, slippage_bps=0)
results = simulate_execution(
target_weights={"000001.SZ": 0.5},
portfolio_value=1_000_000.0,
config=cfg,
)
assert results[0].executed_value == 500_000.0
assert results[0].commission == 0.0
assert results[0].stamp_tax == 0.0
assert results[0].slippage_cost == 0.0
# ── total_costs / total_turnover ──────────────────────────────
def test_total_costs_empty():
"""空结果列表 → 总成本 0。"""
assert total_costs([]) == 0.0
def test_total_costs_sum():
"""总成本 = sum(r.total_cost)。"""
cfg = ExecutionConfig()
results = simulate_execution(
target_weights={"000001.SZ": 0.5, "600000.SH": -0.3},
portfolio_value=1_000_000.0,
config=cfg,
)
expected = sum(r.total_cost for r in results)
assert total_costs(results) == expected
def test_total_turnover_sum():
"""总换手 = sum(r.executed_value)。"""
cfg = ExecutionConfig()
results = simulate_execution(
target_weights={"000001.SZ": 0.5, "600000.SH": -0.3},
portfolio_value=1_000_000.0,
config=cfg,
)
expected = sum(r.executed_value for r in results)
assert total_turnover(results) == expected
# ── ExecutionResult 数据类 ──────────────────────────────
def test_execution_result_dataclass():
"""ExecutionResult 是 dataclass。"""
r = ExecutionResult(
stock_code="000001.SZ",
target_value=500_000.0,
executed_value=500_250.0,
commission=150.075,
stamp_tax=0.0,
slippage_cost=250.0,
total_cost=400.075,
net_cash_flow=-500_400.075,
)
assert r.stock_code == "000001.SZ"
assert r.total_cost == pytest.approx(400.075)
# ── v1.2.0 Phase 1:止损/止盈(check_stop_loss_take_profit) ──
def test_stop_loss_triggered():
"""亏损超过阈值 → 止损。"""
cfg = ExecutionConfig(stop_loss_pct=0.10)
assert check_stop_loss_take_profit(100.0, 89.0, cfg) == "stop_loss"
def test_stop_loss_exact_boundary():
"""恰好 10% 亏损 → 止损触发(<= -10%)。"""
cfg = ExecutionConfig(stop_loss_pct=0.10)
assert check_stop_loss_take_profit(100.0, 90.0, cfg) == "stop_loss"
def test_stop_loss_not_triggered():
"""9% 亏损 → 未触发。"""
cfg = ExecutionConfig(stop_loss_pct=0.10)
assert check_stop_loss_take_profit(100.0, 91.0, cfg) is None
def test_take_profit_triggered():
"""盈利超过阈值 → 止盈。"""
cfg = ExecutionConfig(take_profit_pct=0.20)
assert check_stop_loss_take_profit(100.0, 121.0, cfg) == "take_profit"
def test_take_profit_not_triggered():
"""15% 盈利 → 未触发(阈值 20%)。"""
cfg = ExecutionConfig(take_profit_pct=0.20)
assert check_stop_loss_take_profit(100.0, 115.0, cfg) is None
def test_no_stop_loss_no_take_profit():
"""默认配置(inf)→ 永不触发。"""
cfg = ExecutionConfig() # 默认 inf
assert check_stop_loss_take_profit(100.0, 50.0, cfg) is None
assert check_stop_loss_take_profit(100.0, 200.0, cfg) is None
def test_zero_cost_basis_no_trigger():
"""成本价 <= 0 → 不触发(避免除零)。"""
cfg = ExecutionConfig(stop_loss_pct=0.10)
assert check_stop_loss_take_profit(0.0, 5.0, cfg) is None
def test_negative_stop_loss_raises():
"""stop_loss_pct <= 0 应报错。"""
with pytest.raises(ValueError, match="stop_loss_pct"):
ExecutionConfig(stop_loss_pct=0.0)
with pytest.raises(ValueError, match="stop_loss_pct"):
ExecutionConfig(stop_loss_pct=-0.1)
def test_negative_take_profit_raises():
"""take_profit_pct <= 0 应报错。"""
with pytest.raises(ValueError, match="take_profit_pct"):
ExecutionConfig(take_profit_pct=0.0)
# ── v1.2.0 Phase 1:多日仿真(simulate_multi_day) ────────
def test_simulate_multi_day_basic():
"""基本多日仿真。"""
signals = [
("d1", {"A": 0.5, "B": 0.5}),
("d2", {"A": 0.6, "B": 0.4}),
]
prices = [
("d1", {"A": 10.0, "B": 20.0}),
("d2", {"A": 11.0, "B": 21.0}),
]
positions = simulate_multi_day(signals, prices, 1_000_000.0)
assert len(positions) == 2
assert positions[0].date == "d1"
assert positions[1].date == "d2"
def test_simulate_multi_day_empty():
"""空输入 → 空输出。"""
assert simulate_multi_day([], [], 1_000_000.0) == []
def test_simulate_multi_day_length_mismatch_raises():
"""signals / prices 长度不一致 → 报错。"""
with pytest.raises(ValueError, match="same length"):
simulate_multi_day(
[("d1", {"A": 0.5})],
[("d1", {"A": 10.0}), ("d2", {"A": 11.0})],
1_000_000.0,
)
def test_simulate_multi_day_first_day_value_equals_initial():
"""第一天 portfolio_value = initial_cash(无持仓)。"""
signals = [("d1", {"A": 1.0})]
prices = [("d1", {"A": 10.0})]
positions = simulate_multi_day(signals, prices, 1_000_000.0)
# 第一天 NAV = 1_000_000(无持仓),第二天才是调仓后
assert positions[0].portfolio_value == 1_000_000.0
def test_simulate_multi_day_holdings_evolution():
"""调仓后 holdings 演化。
注意:positions[i] 是第 i 天 rebalance 之前的快照。
所以要看 d2 rebalance 后的 holdings,需要看 positions[2](d3 的快照)。
"""
signals = [
("d1", {"A": 0.5, "B": 0.5}),
("d2", {"A": 1.0, "B": 0.0}), # 全仓 A
("d3", {"A": 1.0, "B": 0.0}), # 第三天的快照才能看到 d2 rebalance 后的 holdings
]
prices = [
("d1", {"A": 10.0, "B": 20.0}),
("d2", {"A": 11.0, "B": 21.0}),
("d3", {"A": 12.0, "B": 22.0}),
]
positions = simulate_multi_day(signals, prices, 1_000_000.0)
# d3 的 PRE-trade snapshot 应该只有 A(B 在 d2 被平仓)
assert "B" not in positions[2].holdings
assert "A" in positions[2].holdings
# ── v1.2.0 Phase 1:端到端 POC(run_end_to_end_poc) ─────
def test_run_end_to_end_poc_basic():
"""端到端 POC 基本 smoke test。"""
signals = [
("d1", {"A": 0.5, "B": 0.5}),
("d2", {"A": 0.6, "B": 0.4}),
("d3", {"A": 0.5, "B": 0.5}),
]
prices = [
("d1", {"A": 10.0, "B": 20.0}),
("d2", {"A": 11.0, "B": 21.0}),
("d3", {"A": 12.0, "B": 22.0}),
]
result = run_end_to_end_poc(signals, prices, 1_000_000.0)
assert "positions" in result
assert "nav_series" in result
assert "total_costs" in result
assert "total_turnover" in result
assert "total_rebalances" in result
assert "final_portfolio_value" in result
assert "return_pct" in result
def test_run_end_to_end_poc_nav_increases_with_bull_market():
"""上涨行情:NAV 上升。"""
signals = [
("d1", {"A": 1.0}),
("d2", {"A": 1.0}),
("d3", {"A": 1.0}),
]
prices = [
("d1", {"A": 10.0}),
("d2", {"A": 11.0}),
("d3", {"A": 12.1}),
]
result = run_end_to_end_poc(signals, prices, 1_000_000.0)
nav = result["nav_series"]
# 持续上涨
assert nav.iloc[-1] > nav.iloc[0]
def test_run_end_to_end_poc_with_alpha158_signal():
"""与 alpha158 因子联动(端到端 POC 真实场景)。"""
import numpy as np
from quant_engine.alpha_factors import alpha_005
np.random.seed(123)
n_days = 30
# 模拟单只股票
close = pd.Series(np.cumsum(np.random.randn(n_days)) + 100)
volume = pd.Series(np.random.rand(n_days) * 1000 + 1000)
# 计算 alpha_005(相关性)
alpha_series = alpha_005(close, volume).fillna(0)
# 信号:alpha > 0 时持有,否则空仓
signals = []
prices = []
for i, (date_idx, alpha_val) in enumerate(zip(close.index, alpha_series)):
date = f"d{i + 1}"
target = {"A": 1.0} if alpha_val > 0 else {"A": 0.0}
signals.append((date, target))
prices.append((date, {"A": float(close.iloc[date_idx])}))
result = run_end_to_end_poc(signals, prices, 1_000_000.0)
# 至少有一次调仓
assert result["total_rebalances"] > 0
def test_run_end_to_end_poc_empty_signals():
"""空 signals → NAV 全是 initial_cash。"""
result = run_end_to_end_poc([], [], 1_000_000.0)
assert result["final_portfolio_value"] == 1_000_000.0
assert result["return_pct"] == 0.0
def test_run_end_to_end_poc_length_mismatch_raises():
"""signals / prices 长度不一致 → 报错。"""
with pytest.raises(ValueError, match="same length"):
run_end_to_end_poc(
[("d1", {"A": 1.0})],
[("d1", {"A": 10.0}), ("d2", {"A": 11.0})],
1_000_000.0,
)
def test_run_end_to_end_poc_costs_recorded():
"""有调仓 → total_costs > 0。"""
signals = [("d1", {"A": 1.0})]
prices = [("d1", {"A": 10.0})]
result = run_end_to_end_poc(signals, prices, 1_000_000.0)
assert result["total_costs"] > 0
assert result["total_turnover"] > 0
# ── v1.2.0 Phase 2: T+1 / 涨跌停 / 部分成交 / 买卖价差 ─────
# ── check_t1_settlement ────────────────────────────────────────
def test_t1_disabled_always_allows():
"""T+1 关闭 → 总是可以卖。"""
assert check_t1_settlement("2024-01-01", "2024-01-01", enable_t1=False) is True
assert check_t1_settlement("2024-01-01", "2024-01-02", enable_t1=False) is True
def test_t1_enabled_same_day_blocked():
"""T+1 启用 + 同日 → 不能卖。"""
assert check_t1_settlement("2024-01-01", "2024-01-01", enable_t1=True) is False
def test_t1_enabled_next_day_allowed():
"""T+1 启用 + 次日 → 可以卖。"""
assert check_t1_settlement("2024-01-01", "2024-01-02", enable_t1=True) is True
# ── check_price_limit ────────────────────────────────────────
def test_price_limit_normal_trade():
"""正常价格(昨收±5%)→ 可以成交。"""
cfg = ExecutionConfig()
can_trade, reason = check_price_limit(
prev_close=10.0, current_price=10.3, config=cfg, is_sell=True
)
assert can_trade is True
assert reason == ""
def test_price_limit_up_blocks_buy():
"""涨停(+10%)→ 不能买。"""
cfg = ExecutionConfig(price_limit_up_pct=0.10)
can_trade, reason = check_price_limit(
prev_close=10.0, current_price=11.5, config=cfg, is_sell=False
)
assert can_trade is False
assert "limit_up" in reason
def test_price_limit_down_blocks_sell():
"""跌停(-10%)→ 不能卖。"""
cfg = ExecutionConfig(price_limit_down_pct=0.10)
can_trade, reason = check_price_limit(
prev_close=10.0, current_price=8.5, config=cfg, is_sell=True
)
assert can_trade is False
assert "limit_down" in reason
def test_price_limit_chinext_20pct():
"""创业板 ±20% 限制。"""
cfg = ExecutionConfig(price_limit_up_pct=0.20, price_limit_down_pct=0.20)
# +15% 在创业板允许范围
can, _ = check_price_limit(10.0, 11.5, cfg, is_sell=False)
assert can is True
# +25% 触发涨停
can, _reason = check_price_limit(10.0, 12.5, cfg, is_sell=False)
assert can is False
def test_price_limit_invalid_prev_close():
"""prev_close <= 0 → 不做检查(返回 True)。"""
cfg = ExecutionConfig()
can, _ = check_price_limit(0.0, 10.0, cfg, is_sell=True)
assert can is True
# ── apply_volume_constraint ────────────────────────────────────────
def test_volume_constraint_default_unlimited():
"""默认 max_volume_pct=1.0 → 不限制。"""
cfg = ExecutionConfig()
actual = apply_volume_constraint(target_shares=1000, daily_volume_shares=100, config=cfg)
assert actual == 100.0 # 100 * 1.0 = 100(限制是 100%)
def test_volume_constraint_caps():
"""max_volume_pct=0.05 → 单笔最大 5% 当日量。"""
cfg = ExecutionConfig(max_volume_pct=0.05)
actual = apply_volume_constraint(target_shares=1000, daily_volume_shares=1000, config=cfg)
assert actual == 50.0 # 1000 * 0.05 = 50
def test_volume_constraint_target_below_cap():
"""目标低于上限 → 不变。"""
cfg = ExecutionConfig(max_volume_pct=0.05)
actual = apply_volume_constraint(target_shares=10, daily_volume_shares=1000, config=cfg)
assert actual == 10.0
def test_volume_constraint_zero_volume():
"""当日成交量为 0 → 不能成交。"""
cfg = ExecutionConfig(max_volume_pct=0.05)
actual = apply_volume_constraint(target_shares=100, daily_volume_shares=0, config=cfg)
assert actual == 0.0
def test_volume_constraint_invalid_config_raises():
"""max_volume_pct=0 应报错。"""
with pytest.raises(ValueError, match="max_volume_pct"):
ExecutionConfig(max_volume_pct=0.0)
# ── apply_bid_ask_spread ────────────────────────────────────────
def test_bid_ask_spread_zero_default():
"""默认 spread=0 → 无影响。"""
cfg = ExecutionConfig()
assert apply_bid_ask_spread(1000.0, is_buy=True, config=cfg) == 1000.0
assert apply_bid_ask_spread(1000.0, is_buy=False, config=cfg) == 1000.0
def test_bid_ask_spread_buy():
"""买入:实际支付 = target + spread。"""
cfg = ExecutionConfig(bid_ask_spread_bps=10.0) # 10 bps = 0.1%
actual = apply_bid_ask_spread(1000.0, is_buy=True, config=cfg)
assert actual == pytest.approx(1001.0)
def test_bid_ask_spread_sell():
"""卖出:实际获得 = target - spread。"""
cfg = ExecutionConfig(bid_ask_spread_bps=10.0)
actual = apply_bid_ask_spread(1000.0, is_buy=False, config=cfg)
assert actual == pytest.approx(999.0)
def test_bid_ask_spread_negative_raises():
"""bid_ask_spread_bps < 0 应报错。"""
with pytest.raises(ValueError, match="bid_ask_spread"):
ExecutionConfig(bid_ask_spread_bps=-1.0)
# ── ExecutionConfig 新字段校验 ────────────────────────────────
def test_execution_config_t1_default_false():
"""默认 T+1 关闭。"""
cfg = ExecutionConfig()
assert cfg.enable_t1_settlement is False
def test_execution_config_price_limit_default_10pct():
"""默认涨跌停 ±10%(主板)。"""
cfg = ExecutionConfig()
assert cfg.price_limit_up_pct == 0.10
assert cfg.price_limit_down_pct == 0.10
def test_execution_config_max_volume_default_100pct():
"""默认 max_volume_pct=1.0(不限)。"""
cfg = ExecutionConfig()
assert cfg.max_volume_pct == 1.0
def test_execution_config_invalid_price_limit_raises():
"""price_limit_up_pct > 1 应报错。"""
with pytest.raises(ValueError, match="price_limit_up_pct"):
ExecutionConfig(price_limit_up_pct=1.5)
def test_execution_result_partial_fill_default():
"""ExecutionResult 默认 partial_fill_pct=1.0(全部成交)。"""
r = ExecutionResult(
stock_code="A",
target_value=1000.0,
executed_value=1000.0,
commission=3.0,
stamp_tax=0.0,
slippage_cost=0.0,
total_cost=3.0,
net_cash_flow=-1003.0,
)
assert r.partial_fill_pct == 1.0
assert r.blocked_reason == ""
# ── v1.2.0 O: simulate_with_daily_data + compute_realized_pnl ─────
def test_simulate_with_daily_data_basic():
"""simulate_with_daily_data 应返回 DailyPosition 列表。"""
dates = pd.date_range("2024-01-01", periods=10)
prices = pd.DataFrame(
{f"S{i}": 10 + np.random.rand(10) * 0.1 for i in range(3)},
index=dates,
)
positions = simulate_with_daily_data(prices, initial_cash=1_000_000.0)
assert len(positions) == 10
def test_simulate_with_daily_data_default_equal_weight():
"""默认 target_weights=None → 等权。"""
dates = pd.date_range("2024-01-01", periods=5)
prices = pd.DataFrame(
{f"S{i}": 10 + np.random.rand(5) * 0.1 for i in range(3)},
index=dates,
)
positions = simulate_with_daily_data(prices, initial_cash=300_000.0)
# 第一天 100k / 3 ≈ 33333 per stock
assert positions[1].cash < 300_000.0 # 第一天后 cash 减少
def test_simulate_with_daily_data_custom_weights():
"""自定义 target_weights_history 应生效。"""
dates = pd.date_range("2024-01-01", periods=5)
prices = pd.DataFrame(
{f"S{i}": 10 + np.random.rand(5) * 0.1 for i in range(3)},
index=dates,
)
# 第一天全仓 S0,之后空仓
targets = [{"S0": 1.0, "S1": 0.0, "S2": 0.0}] + [{"S0": 0.0}] * 4
positions = simulate_with_daily_data(
prices,
initial_cash=1_000_000.0,
target_weights_history=targets,
)
assert len(positions) == 5
def test_simulate_with_daily_data_length_mismatch_raises():
"""target_weights_history 长度不一致应报错。"""
dates = pd.date_range("2024-01-01", periods=5)
prices = pd.DataFrame({"S0": [10.0] * 5}, index=dates)
with pytest.raises(ValueError, match="length"):
simulate_with_daily_data(
prices,
target_weights_history=[{"S0": 1.0}],
initial_cash=1_000_000,
)
def test_simulate_with_daily_data_empty():
"""空输入 → 空输出。"""
prices = pd.DataFrame()
positions = simulate_with_daily_data(prices)
assert positions == []
def test_compute_realized_pnl_basic():
"""compute_realized_pnl 应返回 DailyPnL 列表(与 positions 同长度)。"""
dates = pd.date_range("2024-01-01", periods=10)
prices = pd.DataFrame(
{f"S{i}": 10 + np.random.rand(10) * 0.1 for i in range(3)},
index=dates,
)
positions = simulate_with_daily_data(prices, initial_cash=1_000_000.0)
pnl_list = compute_realized_pnl(positions)
assert len(pnl_list) == 10
def test_compute_realized_pnl_empty():
"""空 positions → 空 pnl 列表。"""
pnl_list = compute_realized_pnl([])
assert pnl_list == []
def test_compute_realized_pnl_first_day_zero():
"""第一天没有前值,realized 应为 0(pnl=0)。"""
dates = pd.date_range("2024-01-01", periods=3)
prices = pd.DataFrame({"S0": [10.0, 11.0, 12.0]}, index=dates)
positions = simulate_with_daily_data(prices, initial_cash=1_000_000.0)
pnl_list = compute_realized_pnl(positions)
# 第一天 realized = 0(因为 prev_value = cur_value 时 nav_change = 0)
assert pnl_list[0].total_pnl == 0.0
def test_compute_realized_pnl_sell_realizes():
"""卖出后 realized_pnl 应反映已实现部分。"""
dates = pd.date_range("2024-01-01", periods=3)
# 价格大涨 100%,第二天全卖
prices = pd.DataFrame({"S0": [10.0, 20.0, 20.0]}, index=dates)
targets = [
{"S0": 1.0}, # 第一天买入
{"S0": 0.0}, # 第二天全卖
{"S0": 0.0}, # 第三天空仓
]
positions = simulate_with_daily_data(
prices,
initial_cash=1_000_000.0,
target_weights_history=targets,
)
pnl_list = compute_realized_pnl(positions)
# 第三天(卖出兑现)应有 realized 正利润(cash 从 -800 → 2M = +2M)
assert pnl_list[2].realized_pnl > 0
# ── O3: end-to-end 端到端测试(集成多个函数) ──────────────
def test_execution_end_to_end_bull_market():
"""上涨行情:NAV 应明显增长。"""
dates = pd.date_range("2024-01-01", periods=30)
# 持续上涨 50%
base = 10 + np.arange(30) * 0.5
prices = pd.DataFrame(
{f"S{i}": base + np.random.rand(30) * 0.2 for i in range(5)},
index=dates,
)
# 等权持有
positions = simulate_with_daily_data(prices, initial_cash=1_000_000.0)
# NAV 应增长 ~50%
final_value = positions[-1].portfolio_value
assert final_value > 1_100_000 # 至少 +10%
def test_execution_end_to_end_with_pnl_breakdown():
"""完整 end-to-end:prices → positions → pnl。"""
dates = pd.date_range("2024-01-01", periods=20)
prices = pd.DataFrame(
{f"S{i}": 10 + np.random.rand(20) * 0.1 for i in range(3)},
index=dates,
)
positions = simulate_with_daily_data(prices, initial_cash=1_000_000.0)
pnl_list = compute_realized_pnl(positions)
# 累计 PnL 应等于 final_value - initial_cash(理论上)
total_pnl_sum = sum(p.total_pnl for p in pnl_list)
nav_change = positions[-1].portfolio_value - positions[0].portfolio_value
# 允许微小差异(因为是 NAV 变化减 realized 的累计)
assert abs(total_pnl_sum - nav_change) < 1.0
+682
View File
@@ -0,0 +1,682 @@
"""src/shared/indicators.py v1.2.0 扩充指标测试(25+ 技术指标)。"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from quant_engine.indicators import (
adx,
aroon,
atr,
bbands_pct_b,
bbands_width,
bollinger,
cci,
cmo,
dmi,
ema,
hilo_indicator,
kdj,
macd,
mfi,
median_price,
momentum,
obv,
roc,
sar,
sma,
stddev_pct,
trix,
typical_price,
weighted_close,
williams_r,
wvad,
# v1.2.0 高级指标
vwap,
ichimoku,
parabolic_sar,
ultimate_oscillator,
aroon_oscillator,
ttm_squeeze,
hurst_exponent,
vpt,
chaikin_money_flow,
ease_of_movement,
dpo,
)
@pytest.fixture
def sample_data() -> dict[str, pd.Series]:
"""生成 60 日 sample 行情。"""
np.random.seed(42)
close = pd.Series(np.cumsum(np.random.randn(60)) + 50)
high = close + 1
low = close - 1
volume = pd.Series(np.random.rand(60) * 1000 + 1000)
return {"close": close, "high": high, "low": low, "volume": volume}
# ── sma / ema ─────────────────────────────────────
def test_sma_basic(sample_data):
"""SMA(5) 最后 5 日均值。"""
close = sample_data["close"]
expected = close.iloc[-5:].mean()
assert sma(close, 5).iloc[-1] == pytest.approx(expected)
def test_sma_n_1_raises(sample_data):
"""SMA n<=0 报错。"""
with pytest.raises(ValueError, match="positive"):
sma(sample_data["close"], 0)
def test_ema_basic(sample_data):
"""EMA(5) 应给出有效值。"""
result = ema(sample_data["close"], 5)
assert not result.isna().all()
assert result.iloc[-1] > 0
def test_ema_n_1_raises(sample_data):
with pytest.raises(ValueError, match="positive"):
ema(sample_data["close"], -1)
# ── macd ─────────────────────────────────────
def test_macd_columns(sample_data):
"""MACD 返回 dif / dea / macd 三列。"""
result = macd(sample_data["close"])
assert set(result.columns) == {"dif", "dea", "macd"}
def test_macd_macd_is_diff(sample_data):
"""macd = (dif - dea) * 2。"""
result = macd(sample_data["close"])
expected = (result["dif"] - result["dea"]) * 2
pd.testing.assert_series_equal(result["macd"], expected, check_names=False)
def test_macd_invalid_params(sample_data):
"""MACD 非法参数报错。"""
with pytest.raises(ValueError, match="positive"):
macd(sample_data["close"], fast=0)
# ── bollinger ─────────────────────────────────────
def test_bollinger_columns(sample_data):
"""布林带返回 mid / upper / lower 三列。"""
result = bollinger(sample_data["close"])
assert set(result.columns) == {"mid", "upper", "lower"}
def test_bollinger_upper_greater_than_lower(sample_data):
"""upper > lower(dropna 后)。"""
result = bollinger(sample_data["close"])
valid = result.dropna()
assert (valid["upper"] > valid["lower"]).all()
# ── kdj ─────────────────────────────────────
def test_kdj_columns(sample_data):
"""KDJ 返回 k / d / j 三列。"""
result = kdj(sample_data["high"], sample_data["low"], sample_data["close"])
assert set(result.columns) == {"k", "d", "j"}
def test_kdj_j_is_3k_2d(sample_data):
"""j = 3k - 2d。"""
result = kdj(sample_data["high"], sample_data["low"], sample_data["close"])
expected_j = 3 * result["k"] - 2 * result["d"]
pd.testing.assert_series_equal(result["j"], expected_j, check_names=False)
# ── atr ─────────────────────────────────────
def test_atr_positive(sample_data):
"""ATR 应为正。"""
result = atr(sample_data["high"], sample_data["low"], sample_data["close"])
valid = result.dropna()
assert (valid >= 0).all()
def test_atr_constant_range(sample_data):
"""固定振幅 → ATR 等于振幅。"""
close = pd.Series([10.0] * 20)
high = close + 1
low = close - 1
result = atr(high, low, close, n=14)
assert result.dropna().iloc[-1] == pytest.approx(2.0)
# ── adx ─────────────────────────────────────
def test_adx_columns(sample_data):
"""ADX 返回 pdi / ndi / adx 三列。"""
result = adx(sample_data["high"], sample_data["low"], sample_data["close"])
assert set(result.columns) == {"pdi", "ndi", "adx"}
# ── cci ─────────────────────────────────────
def test_cci_basic(sample_data):
"""CCI 应给出有效值。"""
result = cci(sample_data["high"], sample_data["low"], sample_data["close"])
valid = result.dropna()
assert valid.shape[0] > 0
# ── obv ─────────────────────────────────────
def test_obv_cumsum(sample_data):
"""OBV 是有向量的累积。"""
result = obv(sample_data["close"], sample_data["volume"])
# 第一个有效 OBV 是第二个交易日
assert not pd.isna(result.iloc[-1])
# ── mfi ─────────────────────────────────────
def test_mfi_range(sample_data):
"""MFI 应在 [0, 100]。"""
result = mfi(
sample_data["high"],
sample_data["low"],
sample_data["close"],
sample_data["volume"],
)
valid = result.dropna()
assert ((valid >= 0) & (valid <= 100)).all()
# ── roc ─────────────────────────────────────
def test_roc_basic(sample_data):
"""ROC(12) 最后 12 期变化率。"""
close = sample_data["close"]
result = roc(close, 12).dropna()
assert result.iloc[-1] == pytest.approx(
(close.iloc[-1] - close.iloc[-13]) / close.iloc[-13] * 100,
)
# ── momentum ─────────────────────────────────────
def test_momentum_basic(sample_data):
"""动量 = close - close.shift(10)。"""
close = sample_data["close"]
result = momentum(close, 10).dropna()
assert result.iloc[-1] == pytest.approx(close.iloc[-1] - close.iloc[-11])
# ── trix ─────────────────────────────────────
def test_trix_basic(sample_data):
"""TRIX 应给出有效值。"""
result = trix(sample_data["close"])
valid = result.dropna()
assert valid.shape[0] > 0
# ── wvad ─────────────────────────────────────
def test_wvad_cumsum(sample_data):
"""WVAD 应是累积。"""
result = wvad(
sample_data["close"],
sample_data["high"],
sample_data["low"],
sample_data["volume"],
)
assert isinstance(result, pd.Series)
assert not pd.isna(result.iloc[-1])
# ── sar ─────────────────────────────────────
def test_sar_basic(sample_data):
"""SAR 应给出非空序列。"""
result = sar(sample_data["high"], sample_data["low"])
assert len(result) == len(sample_data["high"])
assert not result.isna().all()
# ── stddev_pct ─────────────────────────────────────
def test_stddev_pct_basic(sample_data):
"""变异系数 = std/mean*100。"""
close = sample_data["close"]
result = stddev_pct(close, 10).dropna()
assert result.iloc[-1] == pytest.approx(
close.iloc[-10:].std() / close.iloc[-10:].mean() * 100,
)
# ── williams_r ─────────────────────────────────────
def test_williams_r_range(sample_data):
"""Williams %R 在 [-100, 0]。"""
result = williams_r(
sample_data["high"],
sample_data["low"],
sample_data["close"],
).dropna()
assert ((result >= -100) & (result <= 0)).all()
# ── cmo ─────────────────────────────────────
def test_cmo_range(sample_data):
"""CMO 在 [-100, 100]。"""
result = cmo(sample_data["close"]).dropna()
assert ((result >= -100) & (result <= 100)).all()
# ── dmi ─────────────────────────────────────
def test_dmi_columns(sample_data):
"""DMI 返回 ADX 的列。"""
result = dmi(sample_data["high"], sample_data["low"])
assert "adx" in result.columns
# ── bbands_width / pct_b ──────────────────────────
def test_bbands_width(sample_data):
"""BBands width 应为非负。"""
result = bbands_width(sample_data["close"]).dropna()
assert (result >= 0).all()
def test_bbands_pct_b(sample_data):
"""%b 应在 [0, 1] 之间(典型情况)。"""
result = bbands_pct_b(sample_data["close"]).dropna()
valid = result[(result >= 0) & (result <= 1)]
assert valid.shape[0] > 0 # 至少有一些正常值
# ── typical / weighted / median price ────────────
def test_typical_price(sample_data):
"""典型价格 = (h+l+c) / 3。"""
result = typical_price(
sample_data["high"],
sample_data["low"],
sample_data["close"],
)
expected = (sample_data["high"] + sample_data["low"] + sample_data["close"]) / 3
pd.testing.assert_series_equal(result, expected, check_names=False)
def test_weighted_close(sample_data):
"""加权收盘价 = (h+l+2c) / 4。"""
result = weighted_close(
sample_data["high"],
sample_data["low"],
sample_data["close"],
)
expected = (sample_data["high"] + sample_data["low"] + sample_data["close"] * 2) / 4
pd.testing.assert_series_equal(result, expected, check_names=False)
def test_median_price(sample_data):
"""中位价 = (h+l) / 2。"""
result = median_price(sample_data["high"], sample_data["low"])
expected = (sample_data["high"] + sample_data["low"]) / 2
pd.testing.assert_series_equal(result, expected, check_names=False)
# ── hilo_indicator ─────────────────────────────────────
def test_hilo_indicator(sample_data):
"""HiLo = (high - low) / close * 100。"""
result = hilo_indicator(
sample_data["high"],
sample_data["low"],
sample_data["close"],
)
valid = result.dropna()
assert (valid >= 0).all()
# ── aroon ─────────────────────────────────────
def test_aroon_columns(sample_data):
"""Aroon 返回 aroon_up / aroon_down 两列。"""
result = aroon(sample_data["high"], sample_data["low"])
assert set(result.columns) == {"aroon_up", "aroon_down"}
def test_aroon_range(sample_data):
"""Aroon_up / Aroon_down 在 [0, 100]。"""
result = aroon(sample_data["high"], sample_data["low"]).dropna()
assert ((result >= 0) & (result <= 100)).all().all()
# ── v1.2.0 高级指标扩展测试 ──────────────────────────────
@pytest.fixture
def ohlc_data() -> dict[str, pd.Series]:
"""OHLCV 测试数据。"""
np.random.seed(42)
idx = pd.date_range("2024-01-01", periods=60)
close = pd.Series(np.cumsum(np.random.randn(60)) + 50, index=idx)
return {
"high": close + 1,
"low": close - 1,
"close": close,
"volume": pd.Series(np.random.rand(60) * 1e6 + 1e5, index=idx),
}
# ── vwap ──────────────────────────────────────
def test_vwap_basic(ohlc_data):
"""VWAP 应返回合理值。"""
result = vwap(ohlc_data["high"], ohlc_data["low"], ohlc_data["close"], ohlc_data["volume"])
assert len(result) == 60
assert result.dropna().iloc[-1] > 0
def test_vwap_close_to_tp(ohlc_data):
"""VWAP 应接近典型价。"""
result = vwap(ohlc_data["high"], ohlc_data["low"], ohlc_data["close"], ohlc_data["volume"])
tp = (ohlc_data["high"] + ohlc_data["low"] + ohlc_data["close"]) / 3
# VWAP 与 TP 的相关性应该高(volume 是常数时)
corr = result.corr(tp)
assert corr > 0.9
# ── ichimoku ──────────────────────────────────────
def test_ichimoku_columns(ohlc_data):
"""Ichimoku 应返回 7 列。"""
result = ichimoku(ohlc_data["high"], ohlc_data["low"], ohlc_data["close"])
assert set(result.columns) == {
"tenkan",
"kijun",
"senkou_a",
"senkou_b",
"chikou",
"cloud_top",
"cloud_bottom",
}
def test_ichimoku_tenkan_faster_than_kijun(ohlc_data):
"""Tenkan(9 期)应比 Kijun(26 期)反应更快。"""
result = ichimoku(ohlc_data["high"], ohlc_data["low"], ohlc_data["close"])
# Tenkan 应该先有非 NaN 值
first_tenkan = result["tenkan"].dropna().index[0]
first_kijun = result["kijun"].dropna().index[0]
assert first_tenkan < first_kijun
# ── parabolic_sar ──────────────────────────────────────
def test_parabolic_sar_basic(ohlc_data):
"""parabolic_sar 应返回完整序列。"""
result = parabolic_sar(ohlc_data["high"], ohlc_data["low"])
assert len(result) == 60
# SAR 至少有一些有效值
valid = result.dropna()
assert valid.shape[0] > 0
# SAR 应为非负(典型 SAR 在价格下方为多头,上方为空头,但通常不会负值)
assert (valid > 0).all()
# ── ultimate_oscillator ──────────────────────────────────────
def test_ultimate_oscillator_range(ohlc_data):
"""UO 应在 [0, 100]。"""
result = ultimate_oscillator(ohlc_data["high"], ohlc_data["low"], ohlc_data["close"])
valid = result.dropna()
assert ((valid >= 0) & (valid <= 100)).all()
# ── aroon_oscillator ──────────────────────────────────────
def test_aroon_oscillator_range(ohlc_data):
"""Aroon Oscillator 应在 [-100, +100]。"""
result = aroon_oscillator(ohlc_data["high"], ohlc_data["low"])
valid = result.dropna()
assert ((valid >= -100) & (valid <= 100)).all()
# ── ttm_squeeze ──────────────────────────────────────
def test_ttm_squeeze_columns(ohlc_data):
"""TTM Squeeze 应返回 squeeze_on + momentum。"""
result = ttm_squeeze(ohlc_data["close"], ohlc_data["high"], ohlc_data["low"])
assert set(result.columns) == {"squeeze_on", "momentum"}
assert result["squeeze_on"].dtype == bool
# ── hurst_exponent ──────────────────────────────────────
def test_hurst_exponent_returns_float(ohlc_data):
"""Hurst 应返回 0-1 之间的浮点数。"""
result = hurst_exponent(ohlc_data["close"])
assert isinstance(result, float)
assert 0.0 <= result <= 1.0
def test_hurst_exponent_random_walk_near_0_5():
"""随机游走的 Hurst 应接近 0.5。"""
np.random.seed(42)
random_walk = pd.Series(np.cumsum(np.random.randn(200)))
h = hurst_exponent(random_walk, max_lag=20)
# 容忍范围 0.3-0.7
assert 0.2 <= h <= 0.8
def test_hurst_exponent_returns_bounded_float():
"""Hurst 返回值应在 (0, 1] 内。"""
np.random.seed(42)
for _ in range(10):
series = pd.Series(np.cumsum(np.random.randn(100)))
h = hurst_exponent(series, max_lag=20)
assert 0.0 <= h <= 1.5 # 放宽上界,因 finite sample
# ── vpt ──────────────────────────────────────
def test_vpt_basic(ohlc_data):
"""VPT 应有非零值。"""
result = vpt(ohlc_data["close"], ohlc_data["volume"])
assert len(result) == 60
assert result.dropna().iloc[-1] != 0
# ── chaikin_money_flow ──────────────────────────────────────
def test_cmf_range(ohlc_data):
"""CMF 应在 [-1, +1]。"""
result = chaikin_money_flow(
ohlc_data["high"], ohlc_data["low"], ohlc_data["close"], ohlc_data["volume"]
)
valid = result.dropna()
assert ((valid >= -1) & (valid <= 1)).all()
def test_cmf_n_invalid_raises(ohlc_data):
"""CMF n<=0 应报错。"""
with pytest.raises(ValueError, match="positive"):
chaikin_money_flow(
ohlc_data["high"], ohlc_data["low"], ohlc_data["close"], ohlc_data["volume"], n=0
)
# ── ease_of_movement ──────────────────────────────────────
def test_emv_basic(ohlc_data):
"""EMV 应有有效值。"""
result = ease_of_movement(ohlc_data["high"], ohlc_data["low"], ohlc_data["volume"])
assert result.dropna().shape[0] > 0
# ── dpo ──────────────────────────────────────
def test_dpo_basic(ohlc_data):
"""DPO 应有有效值。"""
result = dpo(ohlc_data["close"])
assert result.dropna().shape[0] > 0
def test_dpo_invalid_n_raises(ohlc_data):
"""DPO n<=0 应报错。"""
with pytest.raises(ValueError, match="positive"):
dpo(ohlc_data["close"], n=0)
# ── v1.2.0 第 3 批指标测试(rsi_series / stochastic / aroon_up/down / bollinger_squeeze / on_balance_volume / mass_index) ──
def test_rsi_series_range(ohlc_data):
"""rsi_series 应在 [0, 100]。"""
from quant_engine.indicators import rsi_series
result = rsi_series(ohlc_data["close"])
valid = result.dropna()
assert ((valid >= 0) & (valid <= 100)).all()
def test_rsi_series_up_trend_high():
"""持续上涨 → RSI 高。"""
from quant_engine.indicators import rsi_series
up = pd.Series(np.arange(1, 60, dtype=float))
result = rsi_series(up)
assert result.dropna().iloc[-1] > 70
def test_rsi_series_down_trend_low():
"""持续下跌 → RSI 低。"""
from quant_engine.indicators import rsi_series
down = pd.Series(np.arange(60, 1, -1, dtype=float))
result = rsi_series(down)
assert result.dropna().iloc[-1] < 30
def test_stochastic_columns(ohlc_data):
"""stochastic 应返回 k / d / j 三列。"""
from quant_engine.indicators import stochastic
result = stochastic(ohlc_data["high"], ohlc_data["low"], ohlc_data["close"])
assert set(result.columns) == {"k", "d", "j"}
def test_stochastic_range(ohlc_data):
"""%K 应在 [0, 100]。"""
from quant_engine.indicators import stochastic
result = stochastic(ohlc_data["high"], ohlc_data["low"], ohlc_data["close"])
valid_k = result["k"].dropna()
assert ((valid_k >= 0) & (valid_k <= 100)).all()
def test_stochastic_j_formula(ohlc_data):
"""j = 3k - 2d。"""
from quant_engine.indicators import stochastic
result = stochastic(ohlc_data["high"], ohlc_data["low"], ohlc_data["close"])
expected_j = 3 * result["k"] - 2 * result["d"]
pd.testing.assert_series_equal(result["j"], expected_j, check_names=False)
def test_aroon_up_down(ohlc_data):
"""aroon_up + aroon_down 应在 [0, 100]。"""
from quant_engine.indicators import aroon_down, aroon_up
up = aroon_up(ohlc_data["high"], ohlc_data["low"])
down = aroon_down(ohlc_data["high"], ohlc_data["low"])
valid_up = up.dropna()
valid_down = down.dropna()
assert ((valid_up >= 0) & (valid_up <= 100)).all()
assert ((valid_down >= 0) & (valid_down <= 100)).all()
def test_bollinger_squeeze_bool():
"""bollinger_squeeze 应返回 bool Series(需要足够长的数据)。"""
from quant_engine.indicators import bollinger_squeeze
# 200 天数据(rolling 100 中位数才有意义)
np.random.seed(42)
close = pd.Series(np.cumsum(np.random.randn(200)) + 100)
# 前 100 天高波动,后 100 天低波动 → 后段应该 squeeze
close.iloc[100:] = close.iloc[100:] * 0.01 + 100
result = bollinger_squeeze(close)
assert result.dtype == bool
# 有效值存在(rolling 100 后)
valid = result.dropna()
assert valid.shape[0] > 0
# 低波动段应有 True
assert valid.sum() > 0
def test_on_balance_volume_matches_obv(ohlc_data):
"""on_balance_volume 应与 obv 结果一致(别名)。"""
from quant_engine.indicators import obv, on_balance_volume
result1 = obv(ohlc_data["close"], ohlc_data["volume"])
result2 = on_balance_volume(ohlc_data["close"], ohlc_data["volume"])
pd.testing.assert_series_equal(result1, result2, check_names=False)
def test_mass_index_basic(ohlc_data):
"""mass_index 应有有效值。"""
from quant_engine.indicators import mass_index
result = mass_index(ohlc_data["high"], ohlc_data["low"])
assert result.dropna().shape[0] > 0
# 反转点通常 > 27(但数据依赖)
assert result.dropna().iloc[-1] > 0
def test_mass_index_invalid_n_raises(ohlc_data):
"""mass_index n<=0 应报错。"""
from quant_engine.indicators import mass_index
with pytest.raises(ValueError, match="positive"):
mass_index(ohlc_data["high"], ohlc_data["low"], n=0)
+137
View File
@@ -0,0 +1,137 @@
"""src/shared/perf_stats.py 单测 —— 覆盖所有 7+3 字段 + 边界路径。"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
def test_perf_stats_returns_all_fields():
"""输出含 7 个源字段 + 3 个 alias 字段 = 10 字段。"""
from quant_engine.perf_stats import perf_stats
np.random.seed(42)
ret = pd.Series(np.random.normal(0.001, 0.02, 100))
out = perf_stats(ret, freq=252)
# 源字段
for k in (
"annual_yield",
"annual_sd",
"sharpe",
"annual_down_sd",
"drawback",
"sortino",
"calmar",
):
assert k in out
# alias 字段
for k in ("ann_return", "annual_volatility", "max_drawdown"):
assert k in out
assert out["annual_yield"] == out["ann_return"]
assert out["annual_sd"] == out["annual_volatility"]
assert out["drawback"] == out["max_drawdown"]
def test_perf_stats_short_returns_zeros():
"""n < 2 → 全 0 字段(避免 std NaN)。"""
from quant_engine.perf_stats import perf_stats
out = perf_stats(pd.Series([0.01]), freq=2)
assert out["annual_yield"] == 0.0
assert out["sharpe"] == 0.0
assert out["drawback"] == 0.0
def test_perf_stats_zero_std_returns_zero_sharpe():
"""std 极小但非零时 → 不会触发 0 fallback(边界)。改测常数序列的其他行为。"""
from quant_engine.perf_stats import perf_stats
ret = pd.Series([0.01] * 10)
out = perf_stats(ret, freq=2)
# annual_yield 应该是固定值
assert abs(out["annual_yield"] - 0.0201) < 0.001
# 净值常数 → drawback = 0
assert out["drawback"] == 0.0
# 无负收益 → sortino = 0
assert out["sortino"] == 0.0
def test_perf_stats_explicit_zero_vol_fallback():
"""单点序列 → 触发 n<2 早 return → sharpe=0。"""
from quant_engine.perf_stats import perf_stats
out = perf_stats(pd.Series([0.0, 0.0, 0.0, 0.0, 0.0]), freq=2)
# 实际是 n=5, mean=0, std=0, annual_yield=0, sharpe=0
assert out["annual_yield"] == 0.0
assert out["sharpe"] == 0.0
def test_perf_stats_drawback_negative():
"""回撤必为负数(或零)。"""
from quant_engine.perf_stats import perf_stats
np.random.seed(0)
ret = pd.Series(np.random.normal(0, 0.05, 200))
out = perf_stats(ret, freq=252)
assert out["drawback"] <= 0.0
def test_perf_stats_no_downside_returns_zero_sortino():
"""无负收益 → down_sd=0 → sortino=0。"""
from quant_engine.perf_stats import perf_stats
# 全正收益(带一点噪声避免完全常数)
ret = pd.Series([0.01, 0.02, 0.015, 0.018, 0.011])
out = perf_stats(ret, freq=2)
# 无负收益 → down_sd = 0(len < 2 或全是 0)
assert out["sortino"] == 0.0
def test_perf_stats_accepts_ndarray():
"""输入可以是 ndarray(_as_series 转 Series)。"""
from quant_engine.perf_stats import perf_stats
arr = np.array([0.01, 0.02, -0.01, 0.015, 0.005, -0.005, 0.01, 0.02, -0.01, 0.015])
out = perf_stats(arr, freq=2)
assert "annual_yield" in out
def test_perf_stats_strips_inf():
"""输入含 inf → _as_series 替换为 NaN 再 dropna。"""
from quant_engine.perf_stats import perf_stats
ret = pd.Series([0.01, 0.02, np.inf, 0.015, -np.inf, 0.005])
out = perf_stats(ret, freq=2)
# 不崩
assert "annual_yield" in out
def test_perf_stats_df_returns_single_row():
"""perf_stats_df 返回单行 DataFrame。"""
from quant_engine.perf_stats import perf_stats_df
ret = pd.Series([0.01, -0.005, 0.015, -0.01, 0.02, 0.005])
out = perf_stats_df(ret, freq=2)
assert isinstance(out, pd.DataFrame)
assert len(out) == 1
assert "annual_yield" in out.columns
def test_batch_perf_stats():
"""批量接口返回多行 DataFrame。"""
from quant_engine.perf_stats import batch_perf_stats
np.random.seed(0)
rets = {
"fund_a": pd.Series(np.random.normal(0.001, 0.02, 100)),
"fund_b": pd.Series(np.random.normal(0.0005, 0.03, 100)),
}
out = batch_perf_stats(rets, freq=252)
assert isinstance(out, pd.DataFrame)
assert len(out) == 2
assert "fund_a" in out.index
assert "fund_b" in out.index
# 每行都含完整 10 字段
assert "annual_yield" in out.columns
assert "ann_return" in out.columns
+184
View File
@@ -0,0 +1,184 @@
"""src/shared/portfolio_decomp.py 单测(v0.18.1 加,提升覆盖率 40% → 80%+)。"""
from __future__ import annotations
import numpy as np
import pytest
# ── l1_rebalance_decompose ────────────────────────────
def test_l1_rebalance_basic():
"""l1_rebalance_decompose 单资产案例:y == X[0] → β ≈ [1, 0, ..., 0]。"""
from quant_engine.portfolio_decomp import l1_rebalance_decompose
np.random.seed(0)
n = 50
X = np.random.randn(n, 3)
# y = X[:, 0](纯资产 0)
y = X[:, 0]
beta = l1_rebalance_decompose(y, X)
assert len(beta) == 3
assert abs(beta.sum() - 1.0) < 1e-4
assert beta[0] > 0.9 # 主要权重在资产 0
def test_l1_rebalance_y_x_shape_mismatch():
"""l1_rebalance_decompose y 与 X 行数不一致 → ValueError。"""
from quant_engine.portfolio_decomp import l1_rebalance_decompose
y = np.array([1.0, 2.0, 3.0])
X = np.array([[1.0, 2.0], [3.0, 4.0]]) # 2 行
with pytest.raises(ValueError, match="行数不一致"):
l1_rebalance_decompose(y, X)
def test_l1_rebalance_bounds_length_mismatch():
"""l1_rebalance_decompose bounds 长度不匹配 → ValueError。"""
from quant_engine.portfolio_decomp import l1_rebalance_decompose
y = np.array([1.0, 2.0])
X = np.array([[1.0, 2.0], [3.0, 4.0]])
bounds = [(0.0, 1.0)] # 长度 1,资产数 2
with pytest.raises(ValueError, match="bounds 长度"):
l1_rebalance_decompose(y, X, bounds=bounds)
def test_l1_rebalance_with_custom_bounds():
"""l1_rebalance_decompose 自定义 bounds → 权重在 bounds 内。"""
from quant_engine.portfolio_decomp import l1_rebalance_decompose
np.random.seed(0)
X = np.random.randn(50, 3)
y = X[:, 0]
bounds = [(0.0, 0.5), (0.0, 1.0), (0.0, 1.0)]
beta = l1_rebalance_decompose(y, X, bounds=bounds)
assert all(b <= 0.5 + 1e-4 for b in beta)
def test_l1_rebalance_list_input():
"""l1_rebalance_decompose 支持 list 输入(自动转 ndarray)。"""
from quant_engine.portfolio_decomp import l1_rebalance_decompose
y = [1.0, 2.0, 3.0, 4.0, 5.0]
X = [[1.0, 0.0], [2.0, 0.0], [3.0, 0.0], [4.0, 0.0], [5.0, 0.0]]
beta = l1_rebalance_decompose(y, X)
assert len(beta) == 2
# y 完全等于 X[:, 0],所以 β ≈ [1, 0]
assert beta[0] > 0.9
def test_l1_rebalance_result_in_bounds():
"""l1_rebalance_decompose 输出 β 在 [0, 1] 内。"""
from quant_engine.portfolio_decomp import l1_rebalance_decompose
np.random.seed(42)
X = np.random.randn(30, 4)
y = X.mean(axis=1)
beta = l1_rebalance_decompose(y, X)
assert all(0.0 <= b <= 1.0 + 1e-9 for b in beta)
assert abs(beta.sum() - 1.0) < 1e-4
# ── risk_parity_weights ────────────────────────────
def test_risk_parity_weights_basic():
"""risk_parity_weights 简单 2 资产协方差 → 权重合理。"""
from quant_engine.portfolio_decomp import risk_parity_weights
# 资产 1 风险 = 1,资产 2 风险 = 2(独立)→ 风险平价权重应偏向资产 1
cov = np.array(
[
[1.0, 0.0],
[0.0, 4.0], # 高波动
]
)
w = risk_parity_weights(cov)
assert len(w) == 2
assert abs(w.sum() - 1.0) < 1e-4
# 低波动资产应有更高权重
assert w[0] > w[1]
def test_risk_parity_weights_correlated():
"""risk_parity_weights 相关资产。"""
from quant_engine.portfolio_decomp import risk_parity_weights
np.random.seed(0)
A = np.random.randn(100, 3)
cov = np.cov(A.T)
w = risk_parity_weights(cov)
assert abs(w.sum() - 1.0) < 1e-3
assert all(w > 0)
def test_risk_parity_weights_zero_variance():
"""risk_parity_weights 全 0 方差 → 退化路径(返回等权 fallback)。"""
from quant_engine.portfolio_decomp import risk_parity_weights
cov = np.zeros((3, 3))
w = risk_parity_weights(cov)
assert len(w) == 3
# 全 0 时退化,结果可能异常但函数不应崩
assert abs(w.sum() - 1.0) < 0.5 # 容忍大误差
# ── mean_variance_weights ────────────────────────────
def test_mean_variance_weights_basic():
"""mean_variance_weights 单调收益 + 差异协方差 → 权重偏向高收益。
v1.2.0 修复:原 risk_aversion=1.0 时 SLSQP 把资产 1/2 卡在下边界 1e-4,
导致 w[1] > w[2] 因浮点噪声 flaky。改用 risk_aversion=100(解析解
[0.951, 0.045, 0.004] 在内部,稳定收敛)。
"""
from quant_engine.portfolio_decomp import mean_variance_weights
# mu = [0.10, 0.05, 0.02],cov 不等(资产 1 风险最高)
mu = np.array([0.10, 0.05, 0.02])
cov = np.diag([0.001, 0.01, 0.04]) # 资产 0 风险最低
w = mean_variance_weights(mu, cov, risk_aversion=100.0)
assert len(w) == 3
assert abs(w.sum() - 1.0) < 1e-3
# 资产 0:低风险高收益 → 应有最高权重(解析解 [0.951, 0.045, 0.004])
assert w[0] > w[1] > w[2]
# 与解析解一致(允许优化误差)
assert w[0] == pytest.approx(0.951, abs=0.01)
def test_mean_variance_weights_high_aversion():
"""mean_variance_weights 高风险厌恶 → 权重接近等权(偏好分散)。"""
from quant_engine.portfolio_decomp import mean_variance_weights
mu = np.array([0.1, 0.05, 0.02])
cov = np.diag([0.01, 0.01, 0.01])
w_low = mean_variance_weights(mu, cov, risk_aversion=0.5)
w_high = mean_variance_weights(mu, cov, risk_aversion=10.0)
# 高厌恶 → 权重差异小(更接近等权 1/3)
diff_low = w_low.max() - w_low.min()
diff_high = w_high.max() - w_high.min()
assert diff_high < diff_low
def test_mean_variance_weights_list_input():
"""mean_variance_weights 支持 list 输入。"""
from quant_engine.portfolio_decomp import mean_variance_weights
w = mean_variance_weights([0.1, 0.05], [[0.01, 0.001], [0.001, 0.01]])
assert len(w) == 2
assert abs(w.sum() - 1.0) < 1e-3
def test_mean_variance_weights_zero_mu():
"""mean_variance_weights 全 0 收益 → 等权。"""
from quant_engine.portfolio_decomp import mean_variance_weights
mu = np.zeros(3)
cov = np.eye(3) * 0.01
w = mean_variance_weights(mu, cov)
# 全 0 收益 → 等权
for wi in w:
assert abs(wi - 1.0 / 3) < 0.01