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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user