Compare commits
4
Commits
c9fb5978f0
...
15b283bdf9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15b283bdf9 | ||
|
|
f9b7f2ab1a | ||
|
|
213aa88deb | ||
|
|
b7f77e2a6c |
@@ -24,6 +24,7 @@
|
|||||||
- `data_adapter` — 桥接 qtdb_pro 长表与新模块(rename / long-wide / 复权 / vwap 代理)
|
- `data_adapter` — 桥接 qtdb_pro 长表与新模块(rename / long-wide / 复权 / vwap 代理)
|
||||||
- `backtest` — weight-based 多日仿真(rebalance_table / compute_nav / compare_to_benchmark)
|
- `backtest` — weight-based 多日仿真(rebalance_table / compute_nav / compare_to_benchmark)
|
||||||
- `portfolio_construction` — 多期因子分数 → Top-K → 等权目标权重表
|
- `portfolio_construction` — 多期因子分数 → Top-K → 等权目标权重表
|
||||||
|
- `research_pipeline` — 因子日 → 下一真实交易日 → 显式执行价 → 执行审计(防前视编排)
|
||||||
- `metrics` — 绩效(年化收益 / 波动率 / Sharpe / 最大回撤 / Calmar)
|
- `metrics` — 绩效(年化收益 / 波动率 / Sharpe / 最大回撤 / Calmar)
|
||||||
- `factor_library` — 通用方法(turnover / winsorize / IC / OLS / jb_test)
|
- `factor_library` — 通用方法(turnover / winsorize / IC / OLS / jb_test)
|
||||||
- `portfolio_decomp` — 组合分解(risk_parity / mean_variance / 因子归因)
|
- `portfolio_decomp` — 组合分解(risk_parity / mean_variance / 因子归因)
|
||||||
@@ -61,8 +62,8 @@ from quant_engine.alpha_factors import alpha_001, alpha_005, ALPHA158_REGISTRY
|
|||||||
from quant_engine.execution import (
|
from quant_engine.execution import (
|
||||||
ExecutionConfig, simulate_multi_day_with_audit, simulate_with_daily_data,
|
ExecutionConfig, simulate_multi_day_with_audit, simulate_with_daily_data,
|
||||||
)
|
)
|
||||||
|
from quant_engine.research_pipeline import run_factor_execution_research
|
||||||
from quant_engine.backtest import run_weight_backtest
|
from quant_engine.backtest import run_weight_backtest
|
||||||
from quant_engine.portfolio_construction import scores_to_weight_table
|
|
||||||
from quant_engine.indicators import macd, bollinger, kdj
|
from quant_engine.indicators import macd, bollinger, kdj
|
||||||
from quant_engine.data_adapter import (
|
from quant_engine.data_adapter import (
|
||||||
long_to_wide, wide_to_long, rename_tushare_columns,
|
long_to_wide, wide_to_long, rename_tushare_columns,
|
||||||
@@ -73,8 +74,9 @@ from quant_engine.data_adapter import (
|
|||||||
|
|
||||||
# 端到端:qtdb_pro 长表 → 适配 → alpha158 → execution
|
# 端到端:qtdb_pro 长表 → 适配 → alpha158 → execution
|
||||||
df = load_qtdb_daily(["000001.SZ"], "2024-01-01", with_adj=True)
|
df = load_qtdb_daily(["000001.SZ"], "2024-01-01", with_adj=True)
|
||||||
prices, volumes = prepare_execution_inputs(df)
|
close_prices, volumes = prepare_execution_inputs(df)
|
||||||
result = simulate_with_daily_data(prices, initial_cash=1_000_000.0)
|
open_prices, _ = prepare_execution_inputs(df, price_col="open")
|
||||||
|
result = simulate_with_daily_data(close_prices, initial_cash=1_000_000.0)
|
||||||
|
|
||||||
# 已正确滞后的目标权重 → 现金约束执行 → 唯一来源的成交/拒绝/日末持仓/NAV
|
# 已正确滞后的目标权重 → 现金约束执行 → 唯一来源的成交/拒绝/日末持仓/NAV
|
||||||
execution = simulate_multi_day_with_audit(
|
execution = simulate_multi_day_with_audit(
|
||||||
@@ -92,18 +94,25 @@ execution = simulate_multi_day_with_audit(
|
|||||||
print(execution.nav_series)
|
print(execution.nav_series)
|
||||||
print(execution.daily_executions)
|
print(execution.daily_executions)
|
||||||
|
|
||||||
# 多期因子分数 → Top-K 等权组合 → 稳定回测结果
|
# 多期因子分数(必须是 point-in-time 数据)→ Top-K → 下一交易日 open 执行
|
||||||
rebalance_weights = scores_to_weight_table(
|
factor_execution = run_factor_execution_research(
|
||||||
factor_scores,
|
factor_scores,
|
||||||
top_k=20,
|
top_k=20,
|
||||||
gross_exposure=1.0,
|
execution_prices=open_prices,
|
||||||
|
execution_price_field="open",
|
||||||
|
initial_cash=1_000_000.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# run_weight_backtest 是低层算子:只接受收益区间开始前已经生效的持仓权重。
|
||||||
|
# 不要把 signal-date 的 factor_scores/decision_weights 直接传给它。
|
||||||
backtest = run_weight_backtest(
|
backtest = run_weight_backtest(
|
||||||
weights=rebalance_weights,
|
weights=effective_holding_weights,
|
||||||
stock_returns=daily_returns,
|
stock_returns=daily_returns,
|
||||||
initial_capital=1_000_000.0,
|
initial_capital=1_000_000.0,
|
||||||
benchmark_nav=benchmark_nav,
|
benchmark_nav=benchmark_nav,
|
||||||
)
|
)
|
||||||
|
print(factor_execution.schedule.signal_to_execution)
|
||||||
|
print(factor_execution.execution.daily_executions)
|
||||||
print(backtest.stats())
|
print(backtest.stats())
|
||||||
print(backtest.benchmark_report())
|
print(backtest.benchmark_report())
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -76,8 +76,9 @@ def compute_nav_from_weights(
|
|||||||
) -> pd.Series:
|
) -> pd.Series:
|
||||||
"""从调仓表(日期 × 股票权重)+ 个股日收益 → 净值曲线。
|
"""从调仓表(日期 × 股票权重)+ 个股日收益 → 净值曲线。
|
||||||
|
|
||||||
假设:在调仓日之间权重不变(**前向填充**)。
|
假设:输入是该收益测量区间开始前已经生效的持仓权重,并在调仓日之间
|
||||||
调仓日的权重 = `weights.loc[rebalance_date]`。
|
保持不变(**前向填充**)。本函数不会把信号日自动解释为执行日;因子分数
|
||||||
|
应先经交易日历调度和实际执行时点处理,避免把同一时点未知的收益计入。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
weights: 调仓日 × 股票代码 的权重 DataFrame(**0~1**,行和 ≤ 1)
|
weights: 调仓日 × 股票代码 的权重 DataFrame(**0~1**,行和 ≤ 1)
|
||||||
|
|||||||
@@ -287,6 +287,8 @@ def prepare_execution_inputs(
|
|||||||
df: pd.DataFrame,
|
df: pd.DataFrame,
|
||||||
stock_col: str = "stock_code",
|
stock_col: str = "stock_code",
|
||||||
date_col: str = "trade_date",
|
date_col: str = "trade_date",
|
||||||
|
*,
|
||||||
|
price_col: str = "close",
|
||||||
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||||
"""长表行情 → execution 输入(prices + volumes 宽表)。
|
"""长表行情 → execution 输入(prices + volumes 宽表)。
|
||||||
|
|
||||||
@@ -294,10 +296,11 @@ def prepare_execution_inputs(
|
|||||||
df: 长表行情(含 close / volume 列,Tushare rename 后)
|
df: 长表行情(含 close / volume 列,Tushare rename 后)
|
||||||
stock_col: 股票代码列名
|
stock_col: 股票代码列名
|
||||||
date_col: 日期列名
|
date_col: 日期列名
|
||||||
|
price_col: 执行价字段,默认 close;防前视研究可显式选择下一交易日 open
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(prices_wide, volumes_wide):
|
(prices_wide, volumes_wide):
|
||||||
- prices_wide: date × stock_code,值=close
|
- prices_wide: date × stock_code,值=price_col
|
||||||
- volumes_wide: date × stock_code,值=volume(若无 volume 列则全 1.0)
|
- volumes_wide: date × stock_code,值=volume(若无 volume 列则全 1.0)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -313,9 +316,9 @@ def prepare_execution_inputs(
|
|||||||
"""
|
"""
|
||||||
if df.empty:
|
if df.empty:
|
||||||
return pd.DataFrame(), pd.DataFrame()
|
return pd.DataFrame(), pd.DataFrame()
|
||||||
if "close" not in df.columns:
|
if price_col not in df.columns:
|
||||||
raise ValueError(f"prepare_execution_inputs: 缺 close 列,实际列={list(df.columns)}")
|
raise ValueError(f"prepare_execution_inputs: 缺 {price_col} 列,实际列={list(df.columns)}")
|
||||||
prices = long_to_wide(df, value_col="close", date_col=date_col, stock_col=stock_col)
|
prices = long_to_wide(df, value_col=price_col, date_col=date_col, stock_col=stock_col)
|
||||||
if "volume" in df.columns:
|
if "volume" in df.columns:
|
||||||
volumes = long_to_wide(df, value_col="volume", date_col=date_col, stock_col=stock_col)
|
volumes = long_to_wide(df, value_col="volume", date_col=date_col, stock_col=stock_col)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""可信研究链路:因子分数经交易日历滞后后进入执行审计。
|
||||||
|
|
||||||
|
本模块只编排现有组合构建与执行组件,不连接账户、券商或实盘订单。
|
||||||
|
时间契约借鉴 Qlib 的 prediction/trade time 分离与 Backtrader 的 next-bar
|
||||||
|
执行语义:signal_date 上形成的目标权重,默认最早在下一交易时点执行。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from pandas.api.types import is_numeric_dtype
|
||||||
|
|
||||||
|
from quant_engine.execution import (
|
||||||
|
ExecutionConfig,
|
||||||
|
ExecutionSimulationResult,
|
||||||
|
simulate_multi_day_with_audit,
|
||||||
|
)
|
||||||
|
from quant_engine.portfolio_construction import scores_to_weight_table
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"TargetWeightSchedule",
|
||||||
|
"FactorExecutionResult",
|
||||||
|
"schedule_target_weights",
|
||||||
|
"run_factor_execution_research",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, eq=False)
|
||||||
|
class TargetWeightSchedule:
|
||||||
|
"""保留决策时间和执行时间的目标权重调度快照。"""
|
||||||
|
|
||||||
|
decision_weights: pd.DataFrame
|
||||||
|
signal_to_execution: pd.Series
|
||||||
|
execution_weights: pd.DataFrame
|
||||||
|
lag_sessions: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, eq=False)
|
||||||
|
class FactorExecutionResult:
|
||||||
|
"""因子到执行审计的一次可复现研究结果。"""
|
||||||
|
|
||||||
|
factor_scores: pd.DataFrame
|
||||||
|
execution_prices: pd.DataFrame
|
||||||
|
schedule: TargetWeightSchedule
|
||||||
|
execution_price_field: str
|
||||||
|
execution: ExecutionSimulationResult
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_datetime_index(index: pd.Index, name: str) -> pd.DatetimeIndex:
|
||||||
|
if not isinstance(index, pd.DatetimeIndex):
|
||||||
|
raise TypeError(f"{name} must use a DatetimeIndex")
|
||||||
|
if not index.is_unique:
|
||||||
|
raise ValueError(f"{name} must contain unique sessions")
|
||||||
|
if not index.is_monotonic_increasing:
|
||||||
|
raise ValueError(f"{name} must be in chronological order")
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_decision_weights(decision_weights: pd.DataFrame) -> None:
|
||||||
|
if not isinstance(decision_weights, pd.DataFrame):
|
||||||
|
raise TypeError(
|
||||||
|
f"decision_weights must be a pandas DataFrame, got {type(decision_weights).__name__}"
|
||||||
|
)
|
||||||
|
_validate_datetime_index(decision_weights.index, "decision_weights index")
|
||||||
|
if not decision_weights.columns.is_unique:
|
||||||
|
raise ValueError("decision_weights must contain unique asset labels")
|
||||||
|
if not all(is_numeric_dtype(dtype) for dtype in decision_weights.dtypes):
|
||||||
|
raise TypeError("decision_weights must contain numeric values")
|
||||||
|
values = decision_weights.to_numpy(dtype=float)
|
||||||
|
if not np.isfinite(values).all() or (values < 0).any():
|
||||||
|
raise ValueError("decision_weights must be finite and non-negative")
|
||||||
|
if (decision_weights.sum(axis=1) > 1.0 + 1e-12).any():
|
||||||
|
raise ValueError("decision_weights rows must sum to at most 1.0")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_execution_prices(execution_prices: pd.DataFrame) -> pd.DatetimeIndex:
|
||||||
|
if not isinstance(execution_prices, pd.DataFrame):
|
||||||
|
raise TypeError(
|
||||||
|
f"execution_prices must be a pandas DataFrame, got {type(execution_prices).__name__}"
|
||||||
|
)
|
||||||
|
calendar = _validate_datetime_index(execution_prices.index, "execution_prices index")
|
||||||
|
if not execution_prices.columns.is_unique:
|
||||||
|
raise ValueError("execution_prices must contain unique asset labels")
|
||||||
|
if not all(is_numeric_dtype(dtype) for dtype in execution_prices.dtypes):
|
||||||
|
raise TypeError("execution_prices must contain numeric values")
|
||||||
|
return calendar
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_target_weights(
|
||||||
|
decision_weights: pd.DataFrame,
|
||||||
|
trading_calendar: pd.DatetimeIndex,
|
||||||
|
*,
|
||||||
|
lag_sessions: int = 1,
|
||||||
|
) -> TargetWeightSchedule:
|
||||||
|
"""将信号日目标权重映射到后续真实交易日,不做整数行盲移位。
|
||||||
|
|
||||||
|
所有信号日必须属于 ``trading_calendar``,且日历必须包含每个信号对应的
|
||||||
|
未来执行日;无法执行的末尾信号会显式失败,避免被静默丢弃。
|
||||||
|
"""
|
||||||
|
_validate_decision_weights(decision_weights)
|
||||||
|
calendar = _validate_datetime_index(trading_calendar, "trading_calendar")
|
||||||
|
if isinstance(lag_sessions, bool) or not isinstance(lag_sessions, int) or lag_sessions <= 0:
|
||||||
|
raise ValueError("lag_sessions must be a positive integer")
|
||||||
|
|
||||||
|
decision_snapshot = decision_weights.copy(deep=True)
|
||||||
|
if decision_snapshot.empty:
|
||||||
|
execution_weights = decision_snapshot.copy(deep=True)
|
||||||
|
execution_weights.index = pd.DatetimeIndex([], name="execution_date")
|
||||||
|
mapping = pd.Series(
|
||||||
|
calendar[:0],
|
||||||
|
index=decision_snapshot.index.copy(),
|
||||||
|
name="execution_date",
|
||||||
|
)
|
||||||
|
return TargetWeightSchedule(
|
||||||
|
decision_weights=decision_snapshot,
|
||||||
|
signal_to_execution=mapping,
|
||||||
|
execution_weights=execution_weights,
|
||||||
|
lag_sessions=lag_sessions,
|
||||||
|
)
|
||||||
|
|
||||||
|
signal_positions = calendar.get_indexer(decision_snapshot.index)
|
||||||
|
if (signal_positions < 0).any():
|
||||||
|
missing = decision_snapshot.index[signal_positions < 0]
|
||||||
|
raise ValueError(
|
||||||
|
"signal dates must be trading sessions; missing="
|
||||||
|
+ ", ".join(str(date) for date in missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
execution_positions = signal_positions + lag_sessions
|
||||||
|
if (execution_positions >= len(calendar)).any():
|
||||||
|
unavailable = decision_snapshot.index[execution_positions >= len(calendar)]
|
||||||
|
raise ValueError(
|
||||||
|
"trading_calendar lacks a future execution session for signal dates: "
|
||||||
|
+ ", ".join(str(date) for date in unavailable)
|
||||||
|
)
|
||||||
|
|
||||||
|
execution_dates = calendar.take(execution_positions)
|
||||||
|
signal_to_execution = pd.Series(
|
||||||
|
execution_dates,
|
||||||
|
index=decision_snapshot.index.copy(),
|
||||||
|
name="execution_date",
|
||||||
|
)
|
||||||
|
execution_weights = decision_snapshot.copy(deep=True)
|
||||||
|
execution_weights.index = pd.DatetimeIndex(execution_dates, name="execution_date")
|
||||||
|
return TargetWeightSchedule(
|
||||||
|
decision_weights=decision_snapshot,
|
||||||
|
signal_to_execution=signal_to_execution,
|
||||||
|
execution_weights=execution_weights,
|
||||||
|
lag_sessions=lag_sessions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_factor_execution_research(
|
||||||
|
factor_scores: pd.DataFrame,
|
||||||
|
execution_prices: pd.DataFrame,
|
||||||
|
*,
|
||||||
|
top_k: int,
|
||||||
|
execution_price_field: str,
|
||||||
|
lag_sessions: int = 1,
|
||||||
|
gross_exposure: float = 1.0,
|
||||||
|
largest: bool = True,
|
||||||
|
initial_cash: float = 1_000_000.0,
|
||||||
|
config: ExecutionConfig | None = None,
|
||||||
|
) -> FactorExecutionResult:
|
||||||
|
"""运行因子分数 → 目标权重 → 下一交易时点 → 执行审计链路。
|
||||||
|
|
||||||
|
``execution_prices`` 必须代表实际拟执行时点的价格矩阵,例如日频研究中
|
||||||
|
signal 日收盘生成分数后使用下一交易日 ``open``。价格字段名称被保存在
|
||||||
|
结果元数据中,但函数不会猜测或重写价格语义。
|
||||||
|
"""
|
||||||
|
price_field = execution_price_field.strip()
|
||||||
|
if not price_field:
|
||||||
|
raise ValueError("execution_price_field must be non-empty")
|
||||||
|
calendar = _validate_execution_prices(execution_prices)
|
||||||
|
|
||||||
|
factor_snapshot = factor_scores.copy(deep=True)
|
||||||
|
decision_weights = scores_to_weight_table(
|
||||||
|
factor_snapshot,
|
||||||
|
top_k,
|
||||||
|
gross_exposure=gross_exposure,
|
||||||
|
largest=largest,
|
||||||
|
)
|
||||||
|
schedule = schedule_target_weights(
|
||||||
|
decision_weights,
|
||||||
|
calendar,
|
||||||
|
lag_sessions=lag_sessions,
|
||||||
|
)
|
||||||
|
price_snapshot = execution_prices.copy(deep=True)
|
||||||
|
|
||||||
|
target_history: list[tuple[str, dict[str, float]]] = []
|
||||||
|
price_history: list[tuple[str, dict[str, float]]] = []
|
||||||
|
for execution_date, weights in schedule.execution_weights.iterrows():
|
||||||
|
date_label = str(pd.Timestamp(execution_date))
|
||||||
|
target_history.append(
|
||||||
|
(date_label, {asset: float(weight) for asset, weight in weights.items()})
|
||||||
|
)
|
||||||
|
prices = price_snapshot.loc[execution_date]
|
||||||
|
price_history.append(
|
||||||
|
(date_label, {asset: float(price) for asset, price in prices.items()})
|
||||||
|
)
|
||||||
|
|
||||||
|
execution = simulate_multi_day_with_audit(
|
||||||
|
target_history,
|
||||||
|
price_history,
|
||||||
|
initial_cash,
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
return FactorExecutionResult(
|
||||||
|
factor_scores=factor_snapshot,
|
||||||
|
execution_prices=price_snapshot,
|
||||||
|
schedule=schedule,
|
||||||
|
execution_price_field=price_field,
|
||||||
|
execution=execution,
|
||||||
|
)
|
||||||
@@ -266,6 +266,17 @@ def test_prepare_execution_inputs_basic(tushare_long: pd.DataFrame) -> None:
|
|||||||
assert volumes.iloc[0, 0] == pytest.approx(1000.0)
|
assert volumes.iloc[0, 0] == pytest.approx(1000.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_execution_inputs_can_select_next_session_open_price(
|
||||||
|
tushare_long: pd.DataFrame,
|
||||||
|
) -> None:
|
||||||
|
"""显式 price_col=open 时应生成开盘执行价矩阵。"""
|
||||||
|
renamed = rename_tushare_columns(tushare_long)
|
||||||
|
|
||||||
|
prices, _volumes = prepare_execution_inputs(renamed, price_col="open")
|
||||||
|
|
||||||
|
assert prices.iloc[0, 0] == pytest.approx(10.0)
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_execution_inputs_no_volume() -> None:
|
def test_prepare_execution_inputs_no_volume() -> None:
|
||||||
"""无 volume 列 → volumes 全 1.0。"""
|
"""无 volume 列 → volumes 全 1.0。"""
|
||||||
df = pd.DataFrame(
|
df = pd.DataFrame(
|
||||||
@@ -286,6 +297,14 @@ def test_prepare_execution_inputs_missing_close_raises() -> None:
|
|||||||
prepare_execution_inputs(df)
|
prepare_execution_inputs(df)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_execution_inputs_missing_selected_price_raises() -> None:
|
||||||
|
df = pd.DataFrame(
|
||||||
|
{"stock_code": ["A"], "trade_date": ["2024-01-01"], "close": [10.0]}
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="缺 open"):
|
||||||
|
prepare_execution_inputs(df, price_col="open")
|
||||||
|
|
||||||
|
|
||||||
# ── 端到端:长表 → 适配 → alpha158 + execution ──────────────
|
# ── 端到端:长表 → 适配 → alpha158 + execution ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -91,10 +91,10 @@ def test_scores_to_weight_table_constructs_each_rebalance_independently() -> Non
|
|||||||
pd.testing.assert_series_equal(result.iloc[0], changed_result.iloc[0])
|
pd.testing.assert_series_equal(result.iloc[0], changed_result.iloc[0])
|
||||||
|
|
||||||
|
|
||||||
def test_factor_scores_flow_directly_into_weight_backtest() -> None:
|
def test_effective_holding_weights_flow_into_weight_backtest() -> None:
|
||||||
dates = pd.date_range("2026-01-05", periods=3, freq="B")
|
dates = pd.date_range("2026-01-05", periods=3, freq="B")
|
||||||
scores = pd.DataFrame(
|
effective_weights = pd.DataFrame(
|
||||||
{"A": [2.0, 0.0], "B": [1.0, 3.0]},
|
{"A": [1.0, 0.0], "B": [0.0, 1.0]},
|
||||||
index=dates[[0, 2]],
|
index=dates[[0, 2]],
|
||||||
)
|
)
|
||||||
stock_returns = pd.DataFrame(
|
stock_returns = pd.DataFrame(
|
||||||
@@ -102,11 +102,10 @@ def test_factor_scores_flow_directly_into_weight_backtest() -> None:
|
|||||||
index=dates,
|
index=dates,
|
||||||
)
|
)
|
||||||
|
|
||||||
weights = scores_to_weight_table(scores, top_k=1)
|
result = run_weight_backtest(effective_weights, stock_returns)
|
||||||
result = run_weight_backtest(weights, stock_returns)
|
|
||||||
|
|
||||||
pd.testing.assert_series_equal(result.nav, pd.Series([1.1, 1.1, 1.32], index=dates))
|
pd.testing.assert_series_equal(result.nav, pd.Series([1.1, 1.1, 1.32], index=dates))
|
||||||
pd.testing.assert_frame_equal(result.weights, weights)
|
pd.testing.assert_frame_equal(result.weights, effective_weights)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("top_k", [0, -1])
|
@pytest.mark.parametrize("top_k", [0, -1])
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""No-lookahead factor-score to execution-audit integration contracts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from quant_engine.execution import ExecutionConfig
|
||||||
|
from quant_engine.research_pipeline import (
|
||||||
|
FactorExecutionResult,
|
||||||
|
TargetWeightSchedule,
|
||||||
|
run_factor_execution_research,
|
||||||
|
schedule_target_weights,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar() -> pd.DatetimeIndex:
|
||||||
|
return pd.date_range("2026-01-05", periods=4, freq="B")
|
||||||
|
|
||||||
|
|
||||||
|
def _factor_scores() -> pd.DataFrame:
|
||||||
|
dates = _calendar()
|
||||||
|
return pd.DataFrame(
|
||||||
|
{"A": [2.0, 0.0], "B": [1.0, 3.0]},
|
||||||
|
index=dates[:2],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_session_open_prices() -> pd.DataFrame:
|
||||||
|
dates = _calendar()
|
||||||
|
return pd.DataFrame(
|
||||||
|
{"A": [1.0, 10.0, 10.0, 10.0], "B": [1.0, 10.0, 20.0, 20.0]},
|
||||||
|
index=dates,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_target_weights_maps_signal_to_next_trading_session() -> None:
|
||||||
|
dates = _calendar()
|
||||||
|
decision_weights = pd.DataFrame(
|
||||||
|
{"A": [1.0, 0.0], "B": [0.0, 1.0]},
|
||||||
|
index=dates[:2],
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule = schedule_target_weights(decision_weights, dates, lag_sessions=1)
|
||||||
|
|
||||||
|
assert isinstance(schedule, TargetWeightSchedule)
|
||||||
|
assert schedule.lag_sessions == 1
|
||||||
|
pd.testing.assert_series_equal(
|
||||||
|
schedule.signal_to_execution,
|
||||||
|
pd.Series(dates[1:3], index=dates[:2], name="execution_date"),
|
||||||
|
)
|
||||||
|
expected = decision_weights.copy()
|
||||||
|
expected.index = dates[1:3]
|
||||||
|
expected.index.name = "execution_date"
|
||||||
|
pd.testing.assert_frame_equal(schedule.execution_weights, expected)
|
||||||
|
assert (schedule.execution_weights.index > schedule.signal_to_execution.index).all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_factor_execution_research_uses_next_session_prices() -> None:
|
||||||
|
config = ExecutionConfig(
|
||||||
|
commission_bps=0,
|
||||||
|
stamp_tax_bps=0,
|
||||||
|
slippage_bps=0,
|
||||||
|
min_trade_amount=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = run_factor_execution_research(
|
||||||
|
_factor_scores(),
|
||||||
|
_next_session_open_prices(),
|
||||||
|
top_k=1,
|
||||||
|
execution_price_field="open",
|
||||||
|
initial_cash=1_000.0,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, FactorExecutionResult)
|
||||||
|
assert result.execution_price_field == "open"
|
||||||
|
assert result.execution.daily_executions[0].date == str(_calendar()[1])
|
||||||
|
assert result.execution.positions[0].holdings == {"A": 100.0}
|
||||||
|
assert result.execution.positions[1].holdings == {"B": 50.0}
|
||||||
|
assert result.execution.final_portfolio_value == pytest.approx(1_000.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_factor_execution_result_snapshots_research_inputs() -> None:
|
||||||
|
scores = _factor_scores()
|
||||||
|
prices = _next_session_open_prices()
|
||||||
|
|
||||||
|
result = run_factor_execution_research(
|
||||||
|
scores,
|
||||||
|
prices,
|
||||||
|
top_k=1,
|
||||||
|
execution_price_field="open",
|
||||||
|
)
|
||||||
|
scores.iloc[0, 0] = -999.0
|
||||||
|
prices.iloc[1, 0] = 999.0
|
||||||
|
|
||||||
|
assert result.factor_scores.iloc[0, 0] == 2.0
|
||||||
|
assert result.execution_prices.loc[_calendar()[1], "A"] == 10.0
|
||||||
|
assert result.execution.positions[0].holdings["A"] < 200_000.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("lag_sessions", [0, -1, True])
|
||||||
|
def test_schedule_target_weights_requires_positive_integer_lag(lag_sessions: int) -> None:
|
||||||
|
with pytest.raises(ValueError, match="lag_sessions"):
|
||||||
|
schedule_target_weights(
|
||||||
|
pd.DataFrame({"A": [1.0]}, index=_calendar()[:1]),
|
||||||
|
_calendar(),
|
||||||
|
lag_sessions=lag_sessions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_target_weights_rejects_signal_outside_trading_calendar() -> None:
|
||||||
|
weekend = pd.Timestamp("2026-01-10")
|
||||||
|
with pytest.raises(ValueError, match="signal dates must be trading sessions"):
|
||||||
|
schedule_target_weights(
|
||||||
|
pd.DataFrame({"A": [1.0]}, index=[weekend]),
|
||||||
|
_calendar(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_target_weights_rejects_missing_future_execution_session() -> None:
|
||||||
|
dates = _calendar()
|
||||||
|
with pytest.raises(ValueError, match="future execution session"):
|
||||||
|
schedule_target_weights(
|
||||||
|
pd.DataFrame({"A": [1.0]}, index=dates[-1:]),
|
||||||
|
dates,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_factor_execution_research_requires_explicit_price_field() -> None:
|
||||||
|
with pytest.raises(ValueError, match="execution_price_field"):
|
||||||
|
run_factor_execution_research(
|
||||||
|
_factor_scores(),
|
||||||
|
_next_session_open_prices(),
|
||||||
|
top_k=1,
|
||||||
|
execution_price_field="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_factor_execution_research_accepts_empty_scores() -> None:
|
||||||
|
scores = pd.DataFrame(columns=["A", "B"], index=pd.DatetimeIndex([]), dtype=float)
|
||||||
|
|
||||||
|
result = run_factor_execution_research(
|
||||||
|
scores,
|
||||||
|
_next_session_open_prices(),
|
||||||
|
top_k=1,
|
||||||
|
execution_price_field="open",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.schedule.execution_weights.empty
|
||||||
|
assert result.execution.positions == ()
|
||||||
Reference in New Issue
Block a user