Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c7f0c06a7 | ||
|
|
741d5f1ad3 | ||
|
|
cbb56d2f52 | ||
|
|
524fc73852 | ||
|
|
8ef8e3208e | ||
|
|
dc67f0e982 | ||
|
|
1a60fef6e1 | ||
|
|
a5dc04bf7e | ||
|
|
a3cefe364d | ||
|
|
d6c3614301 | ||
|
|
8addce4a68 | ||
|
|
2071d508d0 | ||
|
|
0568cabda1 | ||
|
|
b2f3da9d66 | ||
|
|
cfa5bed188 | ||
|
|
a9465e6479 |
@@ -25,6 +25,7 @@
|
|||||||
- `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` — 因子日 → 下一真实交易日 → 显式执行价 → 日末估值 → 成本后绩效(防前视编排)
|
- `research_pipeline` — 因子日 → 下一真实交易日 → 显式执行价 → 日末估值 → 成本后绩效(防前视编排)
|
||||||
|
- `artifact` — 版本化、确定性、存储中立的完整 research run 事实表与 manifest
|
||||||
- `attribution` — 基于实际成交后持仓的隔夜 / 日内 / 交易成本逐日收益归因与闭合审计
|
- `attribution` — 基于实际成交后持仓的隔夜 / 日内 / 交易成本逐日收益归因与闭合审计
|
||||||
- `metrics` — 绝对绩效 + 严格日期对齐的 TE / IR / alpha / beta 基准相对绩效
|
- `metrics` — 绝对绩效 + 严格日期对齐的 TE / IR / alpha / beta 基准相对绩效
|
||||||
- `factor_library` — 通用方法(turnover / winsorize / IC / OLS / jb_test)
|
- `factor_library` — 通用方法(turnover / winsorize / IC / OLS / jb_test)
|
||||||
@@ -136,6 +137,46 @@ print(attribution.residual) # 应接近 0;否则说明贡献未闭合到账
|
|||||||
# benchmark_returns 必须与成本后 factor_backtest.returns 使用完全相同的日期索引。
|
# benchmark_returns 必须与成本后 factor_backtest.returns 使用完全相同的日期索引。
|
||||||
print(factor_backtest.benchmark_stats(benchmark_returns))
|
print(factor_backtest.benchmark_stats(benchmark_returns))
|
||||||
|
|
||||||
|
# 下游稳定交付:显式提供代码版本、数据快照和时区,不在核心层写数据库。
|
||||||
|
from quant_engine.artifact import build_research_run_artifact
|
||||||
|
from quant_engine.data_adapter import prepare_asset_return_snapshot
|
||||||
|
from quant_engine.risk import estimate_covariance_snapshot
|
||||||
|
|
||||||
|
risk_date = factor_backtest.position_weights.index[-1].date()
|
||||||
|
market_snapshot = prepare_asset_return_snapshot(
|
||||||
|
qtdb_daily_long,
|
||||||
|
source="qtdb_pro.hq_daily",
|
||||||
|
source_snapshot_id="<upstream-ingestion-snapshot-id>",
|
||||||
|
adjustment="qfq",
|
||||||
|
)
|
||||||
|
risk_snapshot = estimate_covariance_snapshot(
|
||||||
|
market_snapshot.returns,
|
||||||
|
as_of_date=risk_date,
|
||||||
|
lookback_sessions=252,
|
||||||
|
min_observations=120,
|
||||||
|
data_snapshot_id=market_snapshot.data_snapshot_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
artifact = build_research_run_artifact(
|
||||||
|
factor_backtest,
|
||||||
|
run_id="research-run-001",
|
||||||
|
strategy_id="alpha-top20",
|
||||||
|
strategy_name="Alpha Top 20",
|
||||||
|
strategy_version="1.0.0",
|
||||||
|
engine_version="1.2.0",
|
||||||
|
code_revision="<git-sha>",
|
||||||
|
data_snapshot_id=market_snapshot.data_snapshot_id,
|
||||||
|
calendar="CN-A",
|
||||||
|
timezone="Asia/Shanghai",
|
||||||
|
started_at="2026-08-21T10:00:00+08:00",
|
||||||
|
finished_at="2026-08-21T10:01:00+08:00",
|
||||||
|
parameters={"top_k": 20, "lag_sessions": 1},
|
||||||
|
benchmark_id="000300.SH",
|
||||||
|
benchmark_returns=benchmark_returns,
|
||||||
|
risk_snapshots={risk_date: risk_snapshot},
|
||||||
|
)
|
||||||
|
print(artifact.manifest())
|
||||||
|
|
||||||
# run_weight_backtest 是低层算子:只接受收益区间开始前已经生效的持仓权重。
|
# run_weight_backtest 是低层算子:只接受收益区间开始前已经生效的持仓权重。
|
||||||
# 不要把 signal-date 的 factor_scores/decision_weights 直接传给它。
|
# 不要把 signal-date 的 factor_scores/decision_weights 直接传给它。
|
||||||
backtest = run_weight_backtest(
|
backtest = run_weight_backtest(
|
||||||
|
|||||||
@@ -16,6 +16,46 @@
|
|||||||
当前核心不新增依赖。逐日收益归因必须从实际换仓前后持仓、成交记录、执行价和
|
当前核心不新增依赖。逐日收益归因必须从实际换仓前后持仓、成交记录、执行价和
|
||||||
收盘估值推导;因子分数与目标权重只是意图,不能作为成交后归因事实源。
|
收盘估值推导;因子分数与目标权重只是意图,不能作为成交后归因事实源。
|
||||||
|
|
||||||
|
## 2026-08-21:研究运行工件
|
||||||
|
|
||||||
|
- 借鉴 [Qlib Recorder / RecordTemplate](https://github.com/microsoft/qlib/blob/main/qlib/workflow/record_temp.py)
|
||||||
|
将 signal、portfolio analysis 和 risk analysis 分成稳定事实,但不引入 Qlib 运行时;
|
||||||
|
- 借鉴 [MLflow Tracking](https://mlflow.org/docs/latest/tracking/) 的 run / params /
|
||||||
|
metrics / artifacts 分层,但 MLflow 只保留为未来可选 exporter;
|
||||||
|
- HTML、PNG 和 tearsheet 是可再生展示物,不能替代 NAV、成交、持仓、归因和绩效事实。
|
||||||
|
|
||||||
|
因此 `ResearchRunArtifact` 使用显式 `schema_version`、`config_hash`、代码版本和数据
|
||||||
|
快照身份,并提供确定性 JSON / SHA-256 manifest;核心层仍不写数据库或 artifact store。
|
||||||
|
|
||||||
|
schema `1.1.0` 将 Qlib 的独立 risk-analysis artifact 思路与 Riskfolio-Lib 的 Euler
|
||||||
|
component-risk 语义结合,但只保留本项目需要的轻量合同:协方差快照必须声明
|
||||||
|
`snapshot_id`、`as_of_date`、收益频率和年化期数;风险从成交后的实际日末持仓计算,
|
||||||
|
component risk 闭合到年化组合波动,percentage contribution 闭合到 1。未来日期、资产
|
||||||
|
标签不完整和零方差组合都直接失败,不以默认值伪造结果。
|
||||||
|
|
||||||
|
## 2026-08-21:协方差快照估计
|
||||||
|
|
||||||
|
| 项目 | 借鉴内容 | 当前决策 |
|
||||||
|
|---|---|---|
|
||||||
|
| [PyPortfolioOpt risk models](https://github.com/PyPortfolio/PyPortfolioOpt/blob/main/pypfopt/risk_models.py) | 将收益输入、协方差估计器和组合优化解耦;sample / EWM / shrinkage 使用统一标签输出 | 借鉴可替换估计器边界,不引入完整包 |
|
||||||
|
| [scikit-learn covariance](https://github.com/scikit-learn/scikit-learn/blob/main/sklearn/covariance/_shrunk_covariance.py) | 维护成熟的 Ledoit–Wolf / OAS shrinkage 实现 | 未来作为可选 adapter;不复制统计公式 |
|
||||||
|
| [Qlib structured risk model](https://github.com/microsoft/qlib/blob/main/qlib/model/riskmodel/structured.py) | PCA/FA 结构化协方差和固定随机状态 | 留作因子风险模型阶段,不进入当前 baseline |
|
||||||
|
|
||||||
|
当前 `estimate_covariance_snapshot` 只编排 pandas 的 sample covariance:先按 `as_of_date`
|
||||||
|
截断,再取固定 session 窗口,使用 complete-case 行并拒绝历史不足;禁止 pandas 默认的
|
||||||
|
pairwise 样本集合产生含义不一致的矩阵。snapshot ID 对窗口数据、缺失掩码、上游数据
|
||||||
|
快照身份和估计参数做 SHA-256,追加未来数据不会改变历史快照。
|
||||||
|
|
||||||
|
市场适配层现以 `AssetReturnSnapshot` 固化 simple-return 输入:上游 ingestion snapshot ID、
|
||||||
|
数据源、价格字段、复权口径、规范化价格值和缺失掩码共同形成内容寻址 ID;不前向填充
|
||||||
|
停牌/缺失价格。该 ID 同时传入协方差快照和研究运行工件,避免同一研究链出现两套数据
|
||||||
|
身份。
|
||||||
|
|
||||||
|
可选 shrinkage adapter 的评估结论是“保留边界,暂不实现”:当前运行依赖没有声明
|
||||||
|
scikit-learn,本切片也不修改版本或锁文件。未来只有在依赖治理接受后,才以延迟导入
|
||||||
|
直接调用 scikit-learn 的 `LedoitWolf` / `OAS`,并让估计器名称、库版本与参数进入
|
||||||
|
snapshot identity;不复制成熟统计公式,也不让环境中偶然存在的包改变 baseline 行为。
|
||||||
|
|
||||||
## hikyuu 的定位
|
## hikyuu 的定位
|
||||||
|
|
||||||
[hikyuu](https://github.com/fasiondog/hikyuu) 的 SG / MM / CN / PG 部件化思想、
|
[hikyuu](https://github.com/fasiondog/hikyuu) 的 SG / MM / CN / PG 部件化思想、
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Research artifact contract handoff
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
把完整可信研究链固化成存储中立、版本化、确定性的 `ResearchRunArtifact`,供
|
||||||
|
`research_results` 持久化和 `research_platform` 查询:
|
||||||
|
|
||||||
|
- run identity / schema version / config hash / code revision / data snapshot;
|
||||||
|
- signal scores / decision weights / signal-to-execution mapping;
|
||||||
|
- NAV / returns / benchmark / costs;
|
||||||
|
- trades / realized positions / cash;
|
||||||
|
- asset and daily return attribution;
|
||||||
|
- performance including Sortino / TE / IR / alpha / beta;
|
||||||
|
- reproducible covariance snapshots and annualized Euler component-risk facts;
|
||||||
|
- canonical JSON / SHA-256 manifest。
|
||||||
|
|
||||||
|
## Branch stack
|
||||||
|
|
||||||
|
- 当前:`codex/research-artifact-contract-20260821`
|
||||||
|
- 基线:`codex/ledger-attribution-20260821`(Draft PR #4)
|
||||||
|
- 下层:Draft PR #3 → Ready PR #2 → `main`
|
||||||
|
|
||||||
|
不得绕过堆叠顺序直接合并到 `main`。
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `pytest -q`: 540 passed,9 个既有 SciPy warning;
|
||||||
|
- data-adapter focused coverage 77%(包含未连接真实 ClickHouse 的 I/O 便捷函数);
|
||||||
|
- `mypy --strict src/`: 16 source files passed;
|
||||||
|
- changed-scope Ruff: passed;
|
||||||
|
- no runtime dependency added;
|
||||||
|
- no database, network, broker or filesystem write side effect in artifact builder。
|
||||||
|
- 三仓隔离 ClickHouse 黄金链路通过:市场价格 → return snapshot → covariance → artifact →
|
||||||
|
publisher → reader;使用随机 localhost 端口、tmpfs 和自动容器清理。
|
||||||
|
|
||||||
|
## Current risk contract
|
||||||
|
|
||||||
|
- artifact schema:`1.1.0`;
|
||||||
|
- `CovarianceSnapshot` 对输入矩阵深拷贝并显式记录截至日、频率和年化期数;
|
||||||
|
- `risk_snapshots` 按研究交易日映射,可只生成需要的风险观察日;
|
||||||
|
- 使用成交后实际持仓,不包含现金风险资产;协方差资产标签必须与研究资产全集一致;
|
||||||
|
- `covariance_as_of_date` 不得晚于 `trade_date`;无正组合方差时拒绝产物。
|
||||||
|
- `estimate_covariance_snapshot` 从显式数据快照的日收益生成无前视、complete-case、
|
||||||
|
SHA-256 可复现的 per-period sample covariance;不包含 I/O 或未来行。
|
||||||
|
- `prepare_asset_return_snapshot` 从规范化长表行情生成不前向填充的 simple daily returns;
|
||||||
|
显式 ingestion snapshot ID、源/字段/复权口径、价格值和缺失掩码共同形成
|
||||||
|
`asset-returns-v1:<sha256>`,并把同一 ID 传给 covariance 与 run artifact。
|
||||||
|
- shrinkage 适配器本轮不实现:scikit-learn 尚非声明依赖,未来只允许薄适配
|
||||||
|
`LedoitWolf` / `OAS`,不复制公式、不依赖环境偶然安装状态。
|
||||||
|
|
||||||
|
## Next action
|
||||||
|
|
||||||
|
保持 Draft PR #5,不绕过堆叠顺序合并;下游 `research_results` / `research_platform`
|
||||||
|
继续在现有 Draft 分支消费同一数据 lineage。下一阶段优先把 ingestion snapshot ID 从
|
||||||
|
真实 ELT 元数据接入调用方,再在依赖治理通过后单独交付可选 shrinkage adapter。
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
"""Versioned, deterministic research-run artifacts for downstream adapters.
|
||||||
|
|
||||||
|
This module is deliberately storage-neutral. It snapshots a completed
|
||||||
|
``FactorBacktestResult`` into queryable fact tables but never writes a database,
|
||||||
|
starts a service, or talks to a broker. ``research_results`` owns persistence;
|
||||||
|
``research_platform`` owns read models and presentation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from quant_engine.research_pipeline import FactorBacktestResult
|
||||||
|
from quant_engine.risk import CovarianceSnapshot, labeled_component_risk
|
||||||
|
|
||||||
|
RESEARCH_ARTIFACT_SCHEMA_VERSION = "1.1.0"
|
||||||
|
|
||||||
|
RISK_COLUMNS = [
|
||||||
|
"run_id",
|
||||||
|
"trade_date",
|
||||||
|
"asset_id",
|
||||||
|
"weight",
|
||||||
|
"marginal_risk",
|
||||||
|
"component_risk",
|
||||||
|
"risk_contribution",
|
||||||
|
"covariance_snapshot_id",
|
||||||
|
"covariance_as_of_date",
|
||||||
|
"risk_measure",
|
||||||
|
"return_frequency",
|
||||||
|
"periods_per_year",
|
||||||
|
]
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"RESEARCH_ARTIFACT_SCHEMA_VERSION",
|
||||||
|
"ResearchRunArtifact",
|
||||||
|
"build_research_run_artifact",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_copy(frame: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
return frame.copy(deep=True)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, eq=False)
|
||||||
|
class ResearchRunArtifact:
|
||||||
|
"""Immutable-by-interface snapshot of one completed research run."""
|
||||||
|
|
||||||
|
schema_version: str
|
||||||
|
_run: pd.DataFrame
|
||||||
|
_signals: pd.DataFrame
|
||||||
|
_nav: pd.DataFrame
|
||||||
|
_trades: pd.DataFrame
|
||||||
|
_positions: pd.DataFrame
|
||||||
|
_attribution: pd.DataFrame
|
||||||
|
_attribution_daily: pd.DataFrame
|
||||||
|
_risk: pd.DataFrame
|
||||||
|
_performance: pd.DataFrame
|
||||||
|
|
||||||
|
@property
|
||||||
|
def run(self) -> pd.DataFrame:
|
||||||
|
return _frame_copy(self._run)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def nav(self) -> pd.DataFrame:
|
||||||
|
return _frame_copy(self._nav)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def signals(self) -> pd.DataFrame:
|
||||||
|
return _frame_copy(self._signals)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def trades(self) -> pd.DataFrame:
|
||||||
|
return _frame_copy(self._trades)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def positions(self) -> pd.DataFrame:
|
||||||
|
return _frame_copy(self._positions)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attribution(self) -> pd.DataFrame:
|
||||||
|
return _frame_copy(self._attribution)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attribution_daily(self) -> pd.DataFrame:
|
||||||
|
return _frame_copy(self._attribution_daily)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def risk(self) -> pd.DataFrame:
|
||||||
|
return _frame_copy(self._risk)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def performance(self) -> pd.DataFrame:
|
||||||
|
return _frame_copy(self._performance)
|
||||||
|
|
||||||
|
def table_frames(self) -> Mapping[str, pd.DataFrame]:
|
||||||
|
"""Return isolated table snapshots keyed by stable logical table name."""
|
||||||
|
return {
|
||||||
|
"run": self.run,
|
||||||
|
"signals": self.signals,
|
||||||
|
"nav": self.nav,
|
||||||
|
"trades": self.trades,
|
||||||
|
"positions": self.positions,
|
||||||
|
"attribution": self.attribution,
|
||||||
|
"attribution_daily": self.attribution_daily,
|
||||||
|
"risk": self.risk,
|
||||||
|
"performance": self.performance,
|
||||||
|
}
|
||||||
|
|
||||||
|
def canonical_json(self) -> str:
|
||||||
|
"""Serialize tables deterministically for checksums and artifact storage."""
|
||||||
|
payload = {
|
||||||
|
"schema_version": self.schema_version,
|
||||||
|
"tables": {
|
||||||
|
name: _frame_records(frame)
|
||||||
|
for name, frame in self._internal_table_frames().items()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return json.dumps(
|
||||||
|
payload,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content_sha256(self) -> str:
|
||||||
|
return hashlib.sha256(self.canonical_json().encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def manifest(self) -> Mapping[str, object]:
|
||||||
|
"""Return a compact immutable identity and row-count manifest."""
|
||||||
|
return {
|
||||||
|
"schema_version": self.schema_version,
|
||||||
|
"run_id": str(self._run.at[0, "run_id"]),
|
||||||
|
"config_hash": str(self._run.at[0, "config_hash"]),
|
||||||
|
"content_sha256": self.content_sha256,
|
||||||
|
"tables": {
|
||||||
|
name: len(frame) for name, frame in self._internal_table_frames().items()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _internal_table_frames(self) -> Mapping[str, pd.DataFrame]:
|
||||||
|
return {
|
||||||
|
"run": self._run,
|
||||||
|
"signals": self._signals,
|
||||||
|
"nav": self._nav,
|
||||||
|
"trades": self._trades,
|
||||||
|
"positions": self._positions,
|
||||||
|
"attribution": self._attribution,
|
||||||
|
"attribution_daily": self._attribution_daily,
|
||||||
|
"risk": self._risk,
|
||||||
|
"performance": self._performance,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _required_text(value: str, name: str, *, max_length: int | None = None) -> str:
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"{name} must be non-empty")
|
||||||
|
if max_length is not None and len(normalized) > max_length:
|
||||||
|
raise ValueError(f"{name} must contain at most {max_length} characters")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _aware_timestamp(value: str | pd.Timestamp, name: str) -> pd.Timestamp:
|
||||||
|
try:
|
||||||
|
timestamp = pd.Timestamp(value)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise ValueError(f"{name} must be a valid timestamp") from error
|
||||||
|
if timestamp.tzinfo is None:
|
||||||
|
raise ValueError(f"{name} must include a timezone")
|
||||||
|
return timestamp
|
||||||
|
|
||||||
|
|
||||||
|
def _json_value(value: object) -> object:
|
||||||
|
if value is None or isinstance(value, str | bool | int):
|
||||||
|
return value
|
||||||
|
if isinstance(value, float):
|
||||||
|
return value if math.isfinite(value) else None
|
||||||
|
if isinstance(value, np.generic):
|
||||||
|
return _json_value(value.item())
|
||||||
|
if isinstance(value, pd.Timestamp):
|
||||||
|
return value.isoformat()
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.isoformat()
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value.isoformat()
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return {
|
||||||
|
str(key): _json_value(item)
|
||||||
|
for key, item in sorted(value.items(), key=lambda pair: str(pair[0]))
|
||||||
|
}
|
||||||
|
if isinstance(value, list | tuple):
|
||||||
|
return [_json_value(item) for item in value]
|
||||||
|
raise TypeError(f"value of type {type(value).__name__} is not JSON serializable")
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_mapping_json(values: Mapping[str, object]) -> str:
|
||||||
|
normalized = _json_value(values)
|
||||||
|
return json.dumps(
|
||||||
|
normalized,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_records(frame: pd.DataFrame) -> list[dict[str, object]]:
|
||||||
|
return [
|
||||||
|
{str(key): _json_value(value) for key, value in row.items()}
|
||||||
|
for row in frame.to_dict(orient="records")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_nav(
|
||||||
|
result: FactorBacktestResult,
|
||||||
|
run_id: str,
|
||||||
|
benchmark_returns: pd.Series | None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
nav = result.execution.ledger_frame.copy(deep=True)
|
||||||
|
nav.insert(0, "run_id", run_id)
|
||||||
|
nav["trade_date"] = pd.to_datetime(nav["trade_date"]).dt.date
|
||||||
|
nav["total_cost"] = [
|
||||||
|
sum(execution.total_cost for execution in daily.executions)
|
||||||
|
for daily in result.execution.daily_executions
|
||||||
|
]
|
||||||
|
if benchmark_returns is None:
|
||||||
|
nav["benchmark_nav"] = np.nan
|
||||||
|
nav["benchmark_return"] = np.nan
|
||||||
|
nav["excess_ret"] = np.nan
|
||||||
|
else:
|
||||||
|
benchmark = benchmark_returns.astype(float, copy=True)
|
||||||
|
nav["benchmark_nav"] = (1.0 + benchmark).cumprod().to_numpy()
|
||||||
|
nav["benchmark_return"] = benchmark.to_numpy()
|
||||||
|
nav["excess_ret"] = result.returns.to_numpy() - benchmark.to_numpy()
|
||||||
|
return nav
|
||||||
|
|
||||||
|
|
||||||
|
def _build_signals(result: FactorBacktestResult, run_id: str) -> pd.DataFrame:
|
||||||
|
columns = [
|
||||||
|
"run_id",
|
||||||
|
"signal_date",
|
||||||
|
"execution_date",
|
||||||
|
"asset_id",
|
||||||
|
"factor_score",
|
||||||
|
"target_weight",
|
||||||
|
]
|
||||||
|
rows: list[dict[str, object]] = []
|
||||||
|
for signal_date, scores in result.factor_scores.iterrows():
|
||||||
|
execution_date = pd.Timestamp(result.schedule.signal_to_execution.at[signal_date]).date()
|
||||||
|
for asset, score in scores.items():
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"run_id": run_id,
|
||||||
|
"signal_date": pd.Timestamp(signal_date).date(),
|
||||||
|
"execution_date": execution_date,
|
||||||
|
"asset_id": asset,
|
||||||
|
"factor_score": float(score),
|
||||||
|
"target_weight": float(
|
||||||
|
result.schedule.decision_weights.at[signal_date, asset]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return pd.DataFrame(rows, columns=columns)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_trades(result: FactorBacktestResult, run_id: str) -> pd.DataFrame:
|
||||||
|
trades = result.execution.trades_frame.copy(deep=True)
|
||||||
|
trades.insert(0, "run_id", run_id)
|
||||||
|
trades["trade_date"] = pd.to_datetime(trades["trade_date"]).dt.date
|
||||||
|
trades.insert(
|
||||||
|
1,
|
||||||
|
"trade_id",
|
||||||
|
[f"{run_id}:{sequence:08d}" for sequence in range(1, len(trades) + 1)],
|
||||||
|
)
|
||||||
|
signal_by_execution = {
|
||||||
|
pd.Timestamp(execution_date).date(): pd.Timestamp(signal_date).date()
|
||||||
|
for signal_date, execution_date in result.schedule.signal_to_execution.items()
|
||||||
|
}
|
||||||
|
trades["signal_id"] = [
|
||||||
|
f"{run_id}:signal:{signal_by_execution[trade_date].isoformat()}"
|
||||||
|
for trade_date in trades["trade_date"]
|
||||||
|
]
|
||||||
|
trades["total_cost"] = trades["fee"] + trades["slippage"]
|
||||||
|
return trades
|
||||||
|
|
||||||
|
|
||||||
|
def _build_positions(result: FactorBacktestResult, run_id: str) -> pd.DataFrame:
|
||||||
|
columns = [
|
||||||
|
"run_id",
|
||||||
|
"trade_date",
|
||||||
|
"asset_id",
|
||||||
|
"asset_type",
|
||||||
|
"quantity",
|
||||||
|
"mark_price",
|
||||||
|
"market_value",
|
||||||
|
"weight",
|
||||||
|
]
|
||||||
|
rows: list[dict[str, object]] = []
|
||||||
|
weights = result.position_weights
|
||||||
|
cash_weights = result.cash_weights
|
||||||
|
for date_value, position in zip(
|
||||||
|
result.valuation_prices.index,
|
||||||
|
result.execution.positions,
|
||||||
|
strict=True,
|
||||||
|
):
|
||||||
|
session_date = pd.Timestamp(date_value).date()
|
||||||
|
for asset, quantity in position.holdings.items():
|
||||||
|
mark_price = float(result.valuation_prices.at[date_value, asset])
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"run_id": run_id,
|
||||||
|
"trade_date": session_date,
|
||||||
|
"asset_id": asset,
|
||||||
|
"asset_type": "security",
|
||||||
|
"quantity": quantity,
|
||||||
|
"mark_price": mark_price,
|
||||||
|
"market_value": quantity * mark_price,
|
||||||
|
"weight": float(weights.at[date_value, asset]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"run_id": run_id,
|
||||||
|
"trade_date": session_date,
|
||||||
|
"asset_id": "CASH",
|
||||||
|
"asset_type": "cash",
|
||||||
|
"quantity": position.cash,
|
||||||
|
"mark_price": 1.0,
|
||||||
|
"market_value": position.cash,
|
||||||
|
"weight": float(cash_weights.at[date_value]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return pd.DataFrame(rows, columns=columns)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_attribution(
|
||||||
|
result: FactorBacktestResult,
|
||||||
|
run_id: str,
|
||||||
|
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||||
|
contribution = result.return_attribution()
|
||||||
|
rows: list[dict[str, object]] = []
|
||||||
|
for date_value in contribution.overnight.index:
|
||||||
|
for asset in contribution.overnight.columns:
|
||||||
|
overnight = float(contribution.overnight.at[date_value, asset])
|
||||||
|
intraday = float(contribution.intraday.at[date_value, asset])
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"run_id": run_id,
|
||||||
|
"trade_date": pd.Timestamp(date_value).date(),
|
||||||
|
"asset_id": asset,
|
||||||
|
"overnight": overnight,
|
||||||
|
"intraday": intraday,
|
||||||
|
"asset_total": overnight + intraday,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
daily = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"run_id": run_id,
|
||||||
|
"trade_date": contribution.total_return.index.date,
|
||||||
|
"transaction_cost": contribution.transaction_cost.to_numpy(),
|
||||||
|
"explained_return": contribution.explained_return.to_numpy(),
|
||||||
|
"residual": contribution.residual.to_numpy(),
|
||||||
|
"total_return": contribution.total_return.to_numpy(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return pd.DataFrame(rows), daily
|
||||||
|
|
||||||
|
|
||||||
|
def _risk_trade_date(value: object) -> date:
|
||||||
|
try:
|
||||||
|
timestamp = pd.Timestamp(value)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise ValueError("risk snapshot keys must be valid trade dates") from error
|
||||||
|
if pd.isna(timestamp):
|
||||||
|
raise ValueError("risk snapshot keys must be valid trade dates")
|
||||||
|
return date(int(timestamp.year), int(timestamp.month), int(timestamp.day))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_risk(
|
||||||
|
result: FactorBacktestResult,
|
||||||
|
run_id: str,
|
||||||
|
risk_snapshots: Mapping[object, CovarianceSnapshot] | None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
if risk_snapshots is None:
|
||||||
|
return pd.DataFrame(columns=RISK_COLUMNS)
|
||||||
|
if not isinstance(risk_snapshots, Mapping):
|
||||||
|
raise TypeError("risk_snapshots must be a mapping")
|
||||||
|
|
||||||
|
session_by_date = {
|
||||||
|
pd.Timestamp(session).date(): session for session in result.position_weights.index
|
||||||
|
}
|
||||||
|
normalized: dict[date, CovarianceSnapshot] = {}
|
||||||
|
for raw_trade_date, snapshot in risk_snapshots.items():
|
||||||
|
trade_date = _risk_trade_date(raw_trade_date)
|
||||||
|
if trade_date in normalized:
|
||||||
|
raise ValueError(f"duplicate risk snapshot trade date: {trade_date}")
|
||||||
|
if trade_date not in session_by_date:
|
||||||
|
raise ValueError(f"risk snapshot trade date {trade_date} must be a result session")
|
||||||
|
if not isinstance(snapshot, CovarianceSnapshot):
|
||||||
|
raise TypeError("risk snapshot values must be CovarianceSnapshot instances")
|
||||||
|
if snapshot.as_of_date > trade_date:
|
||||||
|
raise ValueError(
|
||||||
|
f"covariance as_of_date {snapshot.as_of_date} must not be after trade date "
|
||||||
|
f"{trade_date}"
|
||||||
|
)
|
||||||
|
normalized[trade_date] = snapshot
|
||||||
|
|
||||||
|
weights_by_date = result.position_weights
|
||||||
|
rows: list[dict[str, object]] = []
|
||||||
|
for trade_date in sorted(normalized):
|
||||||
|
snapshot = normalized[trade_date]
|
||||||
|
session = session_by_date[trade_date]
|
||||||
|
weights = weights_by_date.loc[session].astype(float, copy=True)
|
||||||
|
annualized_covariance = snapshot.covariance * snapshot.periods_per_year
|
||||||
|
decomposition = labeled_component_risk(weights, annualized_covariance)
|
||||||
|
for asset_id in weights.index:
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"run_id": run_id,
|
||||||
|
"trade_date": trade_date,
|
||||||
|
"asset_id": asset_id,
|
||||||
|
"weight": float(weights.loc[asset_id]),
|
||||||
|
"marginal_risk": float(decomposition.marginal.loc[asset_id]),
|
||||||
|
"component_risk": float(decomposition.component.loc[asset_id]),
|
||||||
|
"risk_contribution": float(decomposition.percentage.loc[asset_id]),
|
||||||
|
"covariance_snapshot_id": snapshot.snapshot_id,
|
||||||
|
"covariance_as_of_date": snapshot.as_of_date,
|
||||||
|
"risk_measure": "annualized_volatility",
|
||||||
|
"return_frequency": snapshot.return_frequency,
|
||||||
|
"periods_per_year": snapshot.periods_per_year,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return pd.DataFrame(rows, columns=RISK_COLUMNS)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_performance(
|
||||||
|
result: FactorBacktestResult,
|
||||||
|
run_id: str,
|
||||||
|
benchmark_returns: pd.Series | None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
stats = result.stats()
|
||||||
|
relative = (
|
||||||
|
result.benchmark_stats(benchmark_returns)
|
||||||
|
if benchmark_returns is not None
|
||||||
|
else {
|
||||||
|
"tracking_error": float("nan"),
|
||||||
|
"information_ratio": float("nan"),
|
||||||
|
"alpha": float("nan"),
|
||||||
|
"beta": float("nan"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
total_return = float(result.nav.iloc[-1] - 1.0)
|
||||||
|
return pd.DataFrame(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"run_id": run_id,
|
||||||
|
"total_ret": total_return,
|
||||||
|
"ann_ret": stats["ann_return"],
|
||||||
|
"ann_volatility": stats["ann_volatility"],
|
||||||
|
"sharpe": stats["sharpe"],
|
||||||
|
"sortino": stats["sortino"],
|
||||||
|
"max_dd": stats["max_drawdown"],
|
||||||
|
"calmar": stats["calmar"],
|
||||||
|
"win_rate": stats["win_rate"],
|
||||||
|
"tracking_error": relative["tracking_error"],
|
||||||
|
"ir": relative["information_ratio"],
|
||||||
|
"alpha": relative["alpha"],
|
||||||
|
"beta": relative["beta"],
|
||||||
|
"n_trades": len(result.execution.trades_frame),
|
||||||
|
"n_days": len(result.returns),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_research_run_artifact(
|
||||||
|
result: FactorBacktestResult,
|
||||||
|
*,
|
||||||
|
run_id: str,
|
||||||
|
strategy_id: str,
|
||||||
|
strategy_name: str,
|
||||||
|
strategy_version: str,
|
||||||
|
engine_version: str,
|
||||||
|
code_revision: str,
|
||||||
|
data_snapshot_id: str,
|
||||||
|
calendar: str,
|
||||||
|
timezone: str,
|
||||||
|
started_at: str | pd.Timestamp,
|
||||||
|
finished_at: str | pd.Timestamp,
|
||||||
|
parameters: Mapping[str, object],
|
||||||
|
benchmark_id: str | None = None,
|
||||||
|
benchmark_returns: pd.Series | None = None,
|
||||||
|
risk_snapshots: Mapping[object, CovarianceSnapshot] | None = None,
|
||||||
|
) -> ResearchRunArtifact:
|
||||||
|
"""Snapshot one successful factor backtest into schema-versioned fact tables."""
|
||||||
|
if not isinstance(result, FactorBacktestResult):
|
||||||
|
raise TypeError("result must be a FactorBacktestResult")
|
||||||
|
if result.nav.empty:
|
||||||
|
raise ValueError("result must contain at least one research session")
|
||||||
|
normalized_run_id = _required_text(run_id, "run_id", max_length=64)
|
||||||
|
normalized_strategy_id = _required_text(strategy_id, "strategy_id")
|
||||||
|
normalized_strategy_name = _required_text(strategy_name, "strategy_name")
|
||||||
|
normalized_strategy_version = _required_text(strategy_version, "strategy_version")
|
||||||
|
normalized_engine_version = _required_text(engine_version, "engine_version")
|
||||||
|
normalized_code_revision = _required_text(code_revision, "code_revision")
|
||||||
|
normalized_snapshot = _required_text(data_snapshot_id, "data_snapshot_id")
|
||||||
|
normalized_calendar = _required_text(calendar, "calendar")
|
||||||
|
normalized_timezone = _required_text(timezone, "timezone")
|
||||||
|
if not isinstance(parameters, Mapping):
|
||||||
|
raise TypeError("parameters must be a mapping")
|
||||||
|
|
||||||
|
started = _aware_timestamp(started_at, "started_at")
|
||||||
|
finished = _aware_timestamp(finished_at, "finished_at")
|
||||||
|
if finished < started:
|
||||||
|
raise ValueError("finished_at must not precede started_at")
|
||||||
|
if (benchmark_id is None) != (benchmark_returns is None):
|
||||||
|
raise ValueError("benchmark_id and benchmark_returns must be provided together")
|
||||||
|
normalized_benchmark = ""
|
||||||
|
if benchmark_id is not None:
|
||||||
|
normalized_benchmark = _required_text(benchmark_id, "benchmark_id")
|
||||||
|
result.benchmark_stats(benchmark_returns)
|
||||||
|
|
||||||
|
params_json = _canonical_mapping_json(parameters)
|
||||||
|
config_hash = hashlib.sha256(params_json.encode("utf-8")).hexdigest()
|
||||||
|
run = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"schema_version": RESEARCH_ARTIFACT_SCHEMA_VERSION,
|
||||||
|
"run_id": normalized_run_id,
|
||||||
|
"strategy_id": normalized_strategy_id,
|
||||||
|
"strategy_name": normalized_strategy_name,
|
||||||
|
"strategy_version": normalized_strategy_version,
|
||||||
|
"engine_version": normalized_engine_version,
|
||||||
|
"code_revision": normalized_code_revision,
|
||||||
|
"config_hash": config_hash,
|
||||||
|
"data_snapshot_id": normalized_snapshot,
|
||||||
|
"benchmark_id": normalized_benchmark,
|
||||||
|
"benchmark_alignment_policy": (
|
||||||
|
"exact_session_index" if benchmark_returns is not None else "none"
|
||||||
|
),
|
||||||
|
"frequency": "1d",
|
||||||
|
"calendar": normalized_calendar,
|
||||||
|
"timezone": normalized_timezone,
|
||||||
|
"initial_capital": result.execution.initial_cash,
|
||||||
|
"start_date": result.nav.index[0].date(),
|
||||||
|
"end_date": result.nav.index[-1].date(),
|
||||||
|
"status": "success",
|
||||||
|
"started_at": started,
|
||||||
|
"finished_at": finished,
|
||||||
|
"params_json": params_json,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
attribution, attribution_daily = _build_attribution(result, normalized_run_id)
|
||||||
|
return ResearchRunArtifact(
|
||||||
|
schema_version=RESEARCH_ARTIFACT_SCHEMA_VERSION,
|
||||||
|
_run=run,
|
||||||
|
_signals=_build_signals(result, normalized_run_id),
|
||||||
|
_nav=_build_nav(result, normalized_run_id, benchmark_returns),
|
||||||
|
_trades=_build_trades(result, normalized_run_id),
|
||||||
|
_positions=_build_positions(result, normalized_run_id),
|
||||||
|
_attribution=attribution,
|
||||||
|
_attribution_daily=attribution_daily,
|
||||||
|
_risk=_build_risk(result, normalized_run_id, risk_snapshots),
|
||||||
|
_performance=_build_performance(result, normalized_run_id, benchmark_returns),
|
||||||
|
)
|
||||||
@@ -6,22 +6,27 @@
|
|||||||
- execution.py 需要**宽表**(date × stock_code)prices / volumes
|
- execution.py 需要**宽表**(date × stock_code)prices / volumes
|
||||||
- Tushare 字段命名:`ts_code / vol(手) / amount(千元) / pct_chg`,且**无 vwap 字段**
|
- Tushare 字段命名:`ts_code / vol(手) / amount(千元) / pct_chg`,且**无 vwap 字段**
|
||||||
|
|
||||||
本模块提供 6 个纯函数,让新模块直接吃 qtdb_pro 真实数据:
|
本模块提供可组合的数据适配函数,让新模块直接吃 qtdb_pro 真实数据:
|
||||||
1. `long_to_wide()` — 长表 → 宽表(date × stock_code)
|
1. `long_to_wide()` — 长表 → 宽表(date × stock_code)
|
||||||
2. `wide_to_long()` — 宽表 → 长表
|
2. `wide_to_long()` — 宽表 → 长表
|
||||||
3. `rename_tushare_columns()` — 列名映射(ts_code→stock_code, vol→volume 等)
|
3. `rename_tushare_columns()` — 列名映射(ts_code→stock_code, vol→volume 等)
|
||||||
4. `add_vwap_proxy()` — vwap 代理(Tushare 无 vwap 字段)
|
4. `add_vwap_proxy()` — vwap 代理(Tushare 无 vwap 字段)
|
||||||
5. `apply_adj_factor()` — 复权(hq_daily × hq_adj_factor 前复权)
|
5. `apply_adj_factor()` — 复权(hq_daily × hq_adj_factor 前复权)
|
||||||
6. `prepare_stock_series()` — 单股提取(alpha_factors 输入)
|
6. `prepare_stock_series()` — 单股提取(alpha_factors 输入)
|
||||||
7. `prepare_execution_inputs()` — execution 输入(prices + volumes 宽表)
|
7. `prepare_asset_return_snapshot()` — 带稳定 lineage 的资产日收益
|
||||||
8. `load_qtdb_daily()` — 便捷加载(qtdb_pro.hq_daily + 可选复权)
|
8. `prepare_execution_inputs()` — execution 输入(prices + volumes 宽表)
|
||||||
|
9. `load_qtdb_daily()` — 便捷加载(qtdb_pro.hq_daily + 可选复权)
|
||||||
|
|
||||||
全部纯 pandas/numpy,零新依赖,mypy strict 兼容。
|
全部纯 pandas/numpy,零新依赖,mypy strict 兼容。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -32,12 +37,14 @@ from quant_engine.logging import get_logger
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"AssetReturnSnapshot",
|
||||||
"long_to_wide",
|
"long_to_wide",
|
||||||
"wide_to_long",
|
"wide_to_long",
|
||||||
"rename_tushare_columns",
|
"rename_tushare_columns",
|
||||||
"add_vwap_proxy",
|
"add_vwap_proxy",
|
||||||
"apply_adj_factor",
|
"apply_adj_factor",
|
||||||
"prepare_stock_series",
|
"prepare_stock_series",
|
||||||
|
"prepare_asset_return_snapshot",
|
||||||
"prepare_execution_inputs",
|
"prepare_execution_inputs",
|
||||||
"load_qtdb_daily",
|
"load_qtdb_daily",
|
||||||
]
|
]
|
||||||
@@ -57,6 +64,107 @@ TUSHARE_RENAME: dict[str, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, init=False, eq=False)
|
||||||
|
class AssetReturnSnapshot:
|
||||||
|
"""Immutable-by-interface daily return matrix with reproducible lineage."""
|
||||||
|
|
||||||
|
data_snapshot_id: str
|
||||||
|
source: str
|
||||||
|
source_snapshot_id: str
|
||||||
|
price_field: str
|
||||||
|
adjustment: str
|
||||||
|
return_method: str
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
sessions: int
|
||||||
|
assets: tuple[str, ...]
|
||||||
|
_returns: pd.DataFrame
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
data_snapshot_id: str,
|
||||||
|
source: str,
|
||||||
|
source_snapshot_id: str,
|
||||||
|
price_field: str,
|
||||||
|
adjustment: str,
|
||||||
|
return_method: str,
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
assets: tuple[str, ...],
|
||||||
|
returns: pd.DataFrame,
|
||||||
|
) -> None:
|
||||||
|
for value, name in (
|
||||||
|
(data_snapshot_id, "data_snapshot_id"),
|
||||||
|
(source, "source"),
|
||||||
|
(source_snapshot_id, "source_snapshot_id"),
|
||||||
|
(price_field, "price_field"),
|
||||||
|
(adjustment, "adjustment"),
|
||||||
|
(return_method, "return_method"),
|
||||||
|
):
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise ValueError(f"{name} must be non-empty")
|
||||||
|
if returns.empty or not isinstance(returns.index, pd.DatetimeIndex):
|
||||||
|
raise ValueError("returns must contain a DatetimeIndex and at least one session")
|
||||||
|
if tuple(returns.columns) != assets:
|
||||||
|
raise ValueError("assets must match returns columns")
|
||||||
|
if start_date > end_date:
|
||||||
|
raise ValueError("start_date must not be after end_date")
|
||||||
|
|
||||||
|
object.__setattr__(self, "data_snapshot_id", data_snapshot_id.strip())
|
||||||
|
object.__setattr__(self, "source", source.strip())
|
||||||
|
object.__setattr__(self, "source_snapshot_id", source_snapshot_id.strip())
|
||||||
|
object.__setattr__(self, "price_field", price_field.strip())
|
||||||
|
object.__setattr__(self, "adjustment", adjustment.strip())
|
||||||
|
object.__setattr__(self, "return_method", return_method.strip())
|
||||||
|
object.__setattr__(self, "start_date", start_date)
|
||||||
|
object.__setattr__(self, "end_date", end_date)
|
||||||
|
object.__setattr__(self, "sessions", len(returns))
|
||||||
|
object.__setattr__(self, "assets", assets)
|
||||||
|
object.__setattr__(self, "_returns", returns.copy(deep=True))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def returns(self) -> pd.DataFrame:
|
||||||
|
"""Return an isolated copy so callers cannot mutate the snapshot."""
|
||||||
|
return self._returns.copy(deep=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _non_empty(value: str, name: str) -> str:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise ValueError(f"{name} must be non-empty")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_return_snapshot_id(
|
||||||
|
prices: pd.DataFrame,
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
source_snapshot_id: str,
|
||||||
|
price_field: str,
|
||||||
|
adjustment: str,
|
||||||
|
) -> str:
|
||||||
|
values = prices.to_numpy(dtype=float, copy=True)
|
||||||
|
missing = np.isnan(values)
|
||||||
|
normalized = np.where(missing, 0.0, values).astype("<f8", copy=False)
|
||||||
|
metadata = {
|
||||||
|
"adjustment": adjustment,
|
||||||
|
"assets": [str(asset) for asset in prices.columns],
|
||||||
|
"price_field": price_field,
|
||||||
|
"return_method": "simple",
|
||||||
|
"schema": "asset-returns-v1",
|
||||||
|
"sessions": [timestamp.date().isoformat() for timestamp in prices.index],
|
||||||
|
"shape": list(values.shape),
|
||||||
|
"source": source,
|
||||||
|
"source_snapshot_id": source_snapshot_id,
|
||||||
|
}
|
||||||
|
digest = hashlib.sha256(
|
||||||
|
json.dumps(metadata, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
)
|
||||||
|
digest.update(missing.astype(np.uint8, copy=False).tobytes(order="C"))
|
||||||
|
digest.update(normalized.tobytes(order="C"))
|
||||||
|
return f"asset-returns-v1:{digest.hexdigest()}"
|
||||||
|
|
||||||
|
|
||||||
def long_to_wide(
|
def long_to_wide(
|
||||||
df: pd.DataFrame,
|
df: pd.DataFrame,
|
||||||
value_col: str = "close",
|
value_col: str = "close",
|
||||||
@@ -283,6 +391,98 @@ def prepare_stock_series(
|
|||||||
return series_map
|
return series_map
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_asset_return_snapshot(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
source_snapshot_id: str,
|
||||||
|
price_col: str = "close",
|
||||||
|
adjustment: str = "none",
|
||||||
|
stock_col: str = "stock_code",
|
||||||
|
date_col: str = "trade_date",
|
||||||
|
) -> AssetReturnSnapshot:
|
||||||
|
"""Build deterministic simple daily returns from a long market-price table.
|
||||||
|
|
||||||
|
``source_snapshot_id`` must identify the upstream ingestion snapshot. The
|
||||||
|
resulting ID additionally fingerprints canonical price values and their
|
||||||
|
missing mask, so changed contents cannot retain the same downstream identity.
|
||||||
|
Missing prices are never forward-filled.
|
||||||
|
"""
|
||||||
|
normalized_source = _non_empty(source, "source")
|
||||||
|
normalized_source_snapshot_id = _non_empty(
|
||||||
|
source_snapshot_id,
|
||||||
|
"source_snapshot_id",
|
||||||
|
)
|
||||||
|
normalized_price_col = _non_empty(price_col, "price_col")
|
||||||
|
normalized_adjustment = _non_empty(adjustment, "adjustment")
|
||||||
|
if not isinstance(df, pd.DataFrame):
|
||||||
|
raise TypeError("df must be a pandas DataFrame")
|
||||||
|
if df.empty:
|
||||||
|
raise ValueError("df must contain market prices")
|
||||||
|
required = {date_col, stock_col, normalized_price_col}
|
||||||
|
missing_columns = sorted(required.difference(df.columns))
|
||||||
|
if missing_columns:
|
||||||
|
raise ValueError(f"prepare_asset_return_snapshot: missing columns={missing_columns}")
|
||||||
|
|
||||||
|
market = df[[date_col, stock_col, normalized_price_col]].copy()
|
||||||
|
if any(not isinstance(asset, str) or not asset.strip() for asset in market[stock_col]):
|
||||||
|
raise ValueError("asset labels must be non-empty strings")
|
||||||
|
market[stock_col] = market[stock_col].str.strip()
|
||||||
|
try:
|
||||||
|
normalized_dates = pd.to_datetime(market[date_col], errors="raise")
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise ValueError("trade dates must be valid dates") from error
|
||||||
|
if normalized_dates.isna().any():
|
||||||
|
raise ValueError("trade dates must be valid dates")
|
||||||
|
market[date_col] = normalized_dates.dt.normalize()
|
||||||
|
if market.duplicated(subset=[date_col, stock_col]).any():
|
||||||
|
raise ValueError("duplicate asset/session prices are not allowed")
|
||||||
|
|
||||||
|
try:
|
||||||
|
market[normalized_price_col] = pd.to_numeric(
|
||||||
|
market[normalized_price_col],
|
||||||
|
errors="raise",
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise ValueError("prices must be numeric") from error
|
||||||
|
observed_prices = market[normalized_price_col].dropna().to_numpy(dtype=float)
|
||||||
|
if observed_prices.size == 0 or not np.isfinite(observed_prices).all():
|
||||||
|
raise ValueError("prices must contain positive finite observations")
|
||||||
|
if (observed_prices <= 0.0).any():
|
||||||
|
raise ValueError("prices must contain positive finite observations")
|
||||||
|
|
||||||
|
prices = market.pivot(
|
||||||
|
index=date_col,
|
||||||
|
columns=stock_col,
|
||||||
|
values=normalized_price_col,
|
||||||
|
).sort_index()
|
||||||
|
prices = prices.reindex(sorted(str(asset) for asset in prices.columns), axis="columns")
|
||||||
|
prices = prices.astype(float)
|
||||||
|
if len(prices) < 2:
|
||||||
|
raise ValueError("market prices must contain at least two sessions")
|
||||||
|
returns = prices.pct_change(fill_method=None)
|
||||||
|
assets = tuple(str(asset) for asset in prices.columns)
|
||||||
|
snapshot_id = _asset_return_snapshot_id(
|
||||||
|
prices,
|
||||||
|
source=normalized_source,
|
||||||
|
source_snapshot_id=normalized_source_snapshot_id,
|
||||||
|
price_field=normalized_price_col,
|
||||||
|
adjustment=normalized_adjustment,
|
||||||
|
)
|
||||||
|
return AssetReturnSnapshot(
|
||||||
|
data_snapshot_id=snapshot_id,
|
||||||
|
source=normalized_source,
|
||||||
|
source_snapshot_id=normalized_source_snapshot_id,
|
||||||
|
price_field=normalized_price_col,
|
||||||
|
adjustment=normalized_adjustment,
|
||||||
|
return_method="simple",
|
||||||
|
start_date=prices.index[0].date(),
|
||||||
|
end_date=prices.index[-1].date(),
|
||||||
|
assets=assets,
|
||||||
|
returns=returns,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def prepare_execution_inputs(
|
def prepare_execution_inputs(
|
||||||
df: pd.DataFrame,
|
df: pd.DataFrame,
|
||||||
stock_col: str = "stock_code",
|
stock_col: str = "stock_code",
|
||||||
|
|||||||
@@ -65,6 +65,20 @@ def sharpe_ratio(r: pd.Series, rf: float = 0.0) -> float:
|
|||||||
return (annualized_return(r) - rf) / vol
|
return (annualized_return(r) - rf) / vol
|
||||||
|
|
||||||
|
|
||||||
|
def sortino_ratio(r: pd.Series, rf: float = 0.0) -> float:
|
||||||
|
"""Sortino = (年化收益 - rf) / 年化下行偏差。"""
|
||||||
|
r = _clean(r)
|
||||||
|
if len(r) < 2:
|
||||||
|
return 0.0
|
||||||
|
downside = np.minimum(r.to_numpy(dtype=float), 0.0)
|
||||||
|
downside_deviation = float(
|
||||||
|
np.sqrt(np.mean(np.square(downside))) * np.sqrt(TRADING_DAYS_PER_YEAR)
|
||||||
|
)
|
||||||
|
if downside_deviation == 0:
|
||||||
|
return 0.0
|
||||||
|
return (annualized_return(r) - rf) / downside_deviation
|
||||||
|
|
||||||
|
|
||||||
def max_drawdown(r: pd.Series) -> float:
|
def max_drawdown(r: pd.Series) -> float:
|
||||||
"""最大回撤(负数)。例如 -0.2 表示最大亏 20%。"""
|
"""最大回撤(负数)。例如 -0.2 表示最大亏 20%。"""
|
||||||
r = _clean(r)
|
r = _clean(r)
|
||||||
@@ -120,6 +134,7 @@ def summary(r: pd.Series, rf: float = 0.0) -> Mapping[str, float]:
|
|||||||
"ann_return": ann_ret,
|
"ann_return": ann_ret,
|
||||||
"ann_volatility": ann_vol,
|
"ann_volatility": ann_vol,
|
||||||
"sharpe": sharpe_ratio(r, rf),
|
"sharpe": sharpe_ratio(r, rf),
|
||||||
|
"sortino": sortino_ratio(r, rf),
|
||||||
"max_drawdown": mdd,
|
"max_drawdown": mdd,
|
||||||
"calmar": calmar_ratio(r),
|
"calmar": calmar_ratio(r),
|
||||||
"win_rate": win_rate(r),
|
"win_rate": win_rate(r),
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -14,13 +17,260 @@ from numpy.typing import NDArray
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ComponentRiskResult",
|
"ComponentRiskResult",
|
||||||
|
"CovarianceSnapshot",
|
||||||
"component_var",
|
"component_var",
|
||||||
|
"estimate_covariance_snapshot",
|
||||||
"labeled_component_risk",
|
"labeled_component_risk",
|
||||||
"marginal_risk_contribution",
|
"marginal_risk_contribution",
|
||||||
"risk_contribution",
|
"risk_contribution",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, init=False, eq=False)
|
||||||
|
class CovarianceSnapshot:
|
||||||
|
"""Immutable-by-interface covariance input with explicit time semantics."""
|
||||||
|
|
||||||
|
snapshot_id: str
|
||||||
|
as_of_date: date
|
||||||
|
_covariance: pd.DataFrame
|
||||||
|
return_frequency: str
|
||||||
|
periods_per_year: int
|
||||||
|
method: str
|
||||||
|
window_start_date: date | None
|
||||||
|
window_end_date: date | None
|
||||||
|
observations: int | None
|
||||||
|
lookback_sessions: int | None
|
||||||
|
missing_policy: str
|
||||||
|
data_snapshot_id: str
|
||||||
|
input_sha256: str
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
snapshot_id: str,
|
||||||
|
as_of_date: str | date | pd.Timestamp,
|
||||||
|
covariance: pd.DataFrame,
|
||||||
|
return_frequency: str,
|
||||||
|
periods_per_year: int,
|
||||||
|
method: str = "provided",
|
||||||
|
window_start_date: str | date | pd.Timestamp | None = None,
|
||||||
|
window_end_date: str | date | pd.Timestamp | None = None,
|
||||||
|
observations: int | None = None,
|
||||||
|
lookback_sessions: int | None = None,
|
||||||
|
missing_policy: str = "provided",
|
||||||
|
data_snapshot_id: str = "",
|
||||||
|
input_sha256: str = "",
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(snapshot_id, str) or not snapshot_id.strip():
|
||||||
|
raise ValueError("snapshot_id must be non-empty")
|
||||||
|
if not isinstance(return_frequency, str) or not return_frequency.strip():
|
||||||
|
raise ValueError("return_frequency must be non-empty")
|
||||||
|
if isinstance(periods_per_year, bool) or not isinstance(periods_per_year, int):
|
||||||
|
raise TypeError("periods_per_year must be an integer")
|
||||||
|
if periods_per_year <= 0:
|
||||||
|
raise ValueError("periods_per_year must be positive")
|
||||||
|
if not isinstance(covariance, pd.DataFrame):
|
||||||
|
raise TypeError("covariance must be a pandas DataFrame")
|
||||||
|
if covariance.empty:
|
||||||
|
raise ValueError("covariance must contain at least one asset")
|
||||||
|
if not isinstance(method, str) or not method.strip():
|
||||||
|
raise ValueError("method must be non-empty")
|
||||||
|
if not isinstance(missing_policy, str) or not missing_policy.strip():
|
||||||
|
raise ValueError("missing_policy must be non-empty")
|
||||||
|
for value, name in (
|
||||||
|
(observations, "observations"),
|
||||||
|
(lookback_sessions, "lookback_sessions"),
|
||||||
|
):
|
||||||
|
if value is not None and (
|
||||||
|
isinstance(value, bool) or not isinstance(value, int) or value <= 0
|
||||||
|
):
|
||||||
|
raise ValueError(f"{name} must be a positive integer when provided")
|
||||||
|
if input_sha256 and (
|
||||||
|
len(input_sha256) != 64
|
||||||
|
or any(character not in "0123456789abcdef" for character in input_sha256)
|
||||||
|
):
|
||||||
|
raise ValueError("input_sha256 must be a lowercase SHA-256 digest")
|
||||||
|
|
||||||
|
normalized_as_of = _normalized_date(as_of_date, "as_of_date")
|
||||||
|
normalized_window_start = (
|
||||||
|
None
|
||||||
|
if window_start_date is None
|
||||||
|
else _normalized_date(window_start_date, "window_start_date")
|
||||||
|
)
|
||||||
|
normalized_window_end = (
|
||||||
|
None
|
||||||
|
if window_end_date is None
|
||||||
|
else _normalized_date(window_end_date, "window_end_date")
|
||||||
|
)
|
||||||
|
if (normalized_window_start is None) != (normalized_window_end is None):
|
||||||
|
raise ValueError("window_start_date and window_end_date must be provided together")
|
||||||
|
if (
|
||||||
|
normalized_window_start is not None
|
||||||
|
and normalized_window_end is not None
|
||||||
|
and normalized_window_start > normalized_window_end
|
||||||
|
):
|
||||||
|
raise ValueError("window_start_date must not be after window_end_date")
|
||||||
|
if normalized_window_end is not None and normalized_window_end > normalized_as_of:
|
||||||
|
raise ValueError("window_end_date must not be after as_of_date")
|
||||||
|
|
||||||
|
object.__setattr__(self, "snapshot_id", snapshot_id.strip())
|
||||||
|
object.__setattr__(self, "as_of_date", normalized_as_of)
|
||||||
|
object.__setattr__(self, "_covariance", covariance.copy(deep=True))
|
||||||
|
object.__setattr__(self, "return_frequency", return_frequency.strip())
|
||||||
|
object.__setattr__(self, "periods_per_year", periods_per_year)
|
||||||
|
object.__setattr__(self, "method", method.strip())
|
||||||
|
object.__setattr__(self, "window_start_date", normalized_window_start)
|
||||||
|
object.__setattr__(self, "window_end_date", normalized_window_end)
|
||||||
|
object.__setattr__(self, "observations", observations)
|
||||||
|
object.__setattr__(self, "lookback_sessions", lookback_sessions)
|
||||||
|
object.__setattr__(self, "missing_policy", missing_policy.strip())
|
||||||
|
object.__setattr__(self, "data_snapshot_id", data_snapshot_id.strip())
|
||||||
|
object.__setattr__(self, "input_sha256", input_sha256)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def covariance(self) -> pd.DataFrame:
|
||||||
|
"""Return an isolated copy so callers cannot mutate the snapshot."""
|
||||||
|
return self._covariance.copy(deep=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_date(value: object, name: str) -> date:
|
||||||
|
try:
|
||||||
|
timestamp = pd.Timestamp(value)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise ValueError(f"{name} must be a valid date") from error
|
||||||
|
if pd.isna(timestamp):
|
||||||
|
raise ValueError(f"{name} must be a valid date")
|
||||||
|
return date(int(timestamp.year), int(timestamp.month), int(timestamp.day))
|
||||||
|
|
||||||
|
|
||||||
|
def _positive_integer(value: int, name: str, *, minimum: int = 1) -> int:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
||||||
|
raise ValueError(f"{name} must be an integer of at least {minimum}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _input_fingerprint(window: pd.DataFrame, session_dates: list[date]) -> str:
|
||||||
|
values = window.to_numpy(dtype=float, copy=True)
|
||||||
|
missing = np.isnan(values)
|
||||||
|
normalized = np.where(missing, 0.0, values).astype("<f8", copy=False)
|
||||||
|
metadata = {
|
||||||
|
"assets": [str(asset) for asset in window.columns],
|
||||||
|
"sessions": [session.isoformat() for session in session_dates],
|
||||||
|
"shape": list(values.shape),
|
||||||
|
}
|
||||||
|
digest = hashlib.sha256(
|
||||||
|
json.dumps(metadata, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
)
|
||||||
|
digest.update(missing.astype(np.uint8, copy=False).tobytes(order="C"))
|
||||||
|
digest.update(normalized.tobytes(order="C"))
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_covariance_snapshot(
|
||||||
|
asset_returns: pd.DataFrame,
|
||||||
|
*,
|
||||||
|
as_of_date: str | date | pd.Timestamp,
|
||||||
|
lookback_sessions: int,
|
||||||
|
min_observations: int,
|
||||||
|
data_snapshot_id: str,
|
||||||
|
return_frequency: str = "1d",
|
||||||
|
periods_per_year: int = 252,
|
||||||
|
) -> CovarianceSnapshot:
|
||||||
|
"""Estimate a deterministic per-period sample covariance without look-ahead.
|
||||||
|
|
||||||
|
The selected lookback window is truncated at ``as_of_date`` before any
|
||||||
|
calculation. Rows containing a missing asset return are removed as complete
|
||||||
|
cases, preventing pairwise sample sets from producing an ambiguous matrix.
|
||||||
|
"""
|
||||||
|
if not isinstance(asset_returns, pd.DataFrame):
|
||||||
|
raise TypeError("asset_returns must be a pandas DataFrame")
|
||||||
|
if asset_returns.empty or asset_returns.shape[1] == 0:
|
||||||
|
raise ValueError("asset_returns must contain observations and assets")
|
||||||
|
if not isinstance(asset_returns.index, pd.DatetimeIndex):
|
||||||
|
raise TypeError("asset_returns index must be a DatetimeIndex")
|
||||||
|
if not asset_returns.index.is_unique or not asset_returns.index.is_monotonic_increasing:
|
||||||
|
raise ValueError("asset_returns index must be unique and strictly increasing")
|
||||||
|
if not asset_returns.columns.is_unique:
|
||||||
|
raise ValueError("asset_returns must contain unique asset labels")
|
||||||
|
if any(not isinstance(asset, str) or not asset.strip() for asset in asset_returns.columns):
|
||||||
|
raise ValueError("asset_returns asset labels must be non-empty strings")
|
||||||
|
|
||||||
|
lookback = _positive_integer(lookback_sessions, "lookback_sessions")
|
||||||
|
minimum = _positive_integer(min_observations, "min_observations", minimum=2)
|
||||||
|
if minimum > lookback:
|
||||||
|
raise ValueError("min_observations must not exceed lookback_sessions")
|
||||||
|
normalized_data_snapshot_id = data_snapshot_id.strip()
|
||||||
|
if not normalized_data_snapshot_id:
|
||||||
|
raise ValueError("data_snapshot_id must be non-empty")
|
||||||
|
normalized_as_of = _normalized_date(as_of_date, "as_of_date")
|
||||||
|
|
||||||
|
returns = asset_returns.astype(float, copy=True)
|
||||||
|
values = returns.to_numpy()
|
||||||
|
if np.isinf(values).any():
|
||||||
|
raise ValueError("asset_returns must not contain infinite values")
|
||||||
|
session_dates = [
|
||||||
|
_normalized_date(index_value, "asset_returns index") for index_value in returns.index
|
||||||
|
]
|
||||||
|
if len(set(session_dates)) != len(session_dates):
|
||||||
|
raise ValueError("asset_returns must contain at most one observation per session date")
|
||||||
|
historical_mask = [session <= normalized_as_of for session in session_dates]
|
||||||
|
window = returns.loc[historical_mask].tail(lookback)
|
||||||
|
if window.empty:
|
||||||
|
raise ValueError("asset_returns contain no observations on or before as_of_date")
|
||||||
|
window_dates = [
|
||||||
|
_normalized_date(index_value, "asset_returns index") for index_value in window.index
|
||||||
|
]
|
||||||
|
complete = window.dropna(axis=0, how="any")
|
||||||
|
if len(complete) < minimum:
|
||||||
|
raise ValueError(
|
||||||
|
f"complete observations must be at least {minimum}; received {len(complete)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
covariance = complete.cov(ddof=1)
|
||||||
|
covariance_values = covariance.to_numpy()
|
||||||
|
if not np.isfinite(covariance_values).all():
|
||||||
|
raise ValueError("sample covariance must be finite")
|
||||||
|
input_sha256 = _input_fingerprint(window, window_dates)
|
||||||
|
identity = {
|
||||||
|
"as_of_date": normalized_as_of.isoformat(),
|
||||||
|
"assets": list(returns.columns),
|
||||||
|
"data_snapshot_id": normalized_data_snapshot_id,
|
||||||
|
"estimator": "sample-cov-v1",
|
||||||
|
"input_sha256": input_sha256,
|
||||||
|
"lookback_sessions": lookback,
|
||||||
|
"min_observations": minimum,
|
||||||
|
"missing_policy": "complete_case",
|
||||||
|
"observations": len(complete),
|
||||||
|
"periods_per_year": periods_per_year,
|
||||||
|
"return_frequency": return_frequency,
|
||||||
|
"window_end_date": window_dates[-1].isoformat(),
|
||||||
|
"window_start_date": window_dates[0].isoformat(),
|
||||||
|
}
|
||||||
|
identity_bytes = json.dumps(
|
||||||
|
identity,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
digest = hashlib.sha256(identity_bytes)
|
||||||
|
digest.update(covariance_values.astype("<f8", copy=False).tobytes(order="C"))
|
||||||
|
snapshot_id = f"sample-cov-v1:{digest.hexdigest()}"
|
||||||
|
return CovarianceSnapshot(
|
||||||
|
snapshot_id=snapshot_id,
|
||||||
|
as_of_date=normalized_as_of,
|
||||||
|
covariance=covariance,
|
||||||
|
return_frequency=return_frequency,
|
||||||
|
periods_per_year=periods_per_year,
|
||||||
|
method="sample",
|
||||||
|
window_start_date=window_dates[0],
|
||||||
|
window_end_date=window_dates[-1],
|
||||||
|
observations=len(complete),
|
||||||
|
lookback_sessions=lookback,
|
||||||
|
missing_policy="complete_case",
|
||||||
|
data_snapshot_id=normalized_data_snapshot_id,
|
||||||
|
input_sha256=input_sha256,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True, eq=False)
|
@dataclass(frozen=True, slots=True, eq=False)
|
||||||
class ComponentRiskResult:
|
class ComponentRiskResult:
|
||||||
"""Label-preserving Euler decomposition of portfolio volatility."""
|
"""Label-preserving Euler decomposition of portfolio volatility."""
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""Stable research-run artifact contracts for downstream persistence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from quant_engine.artifact import (
|
||||||
|
RESEARCH_ARTIFACT_SCHEMA_VERSION,
|
||||||
|
ResearchRunArtifact,
|
||||||
|
build_research_run_artifact,
|
||||||
|
)
|
||||||
|
from quant_engine.execution import ExecutionConfig
|
||||||
|
from quant_engine.research_pipeline import FactorBacktestResult, run_factor_backtest_research
|
||||||
|
from quant_engine.risk import CovarianceSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
def _backtest_result() -> FactorBacktestResult:
|
||||||
|
dates = pd.date_range("2026-01-05", periods=4, freq="B")
|
||||||
|
scores = pd.DataFrame(
|
||||||
|
{"A": [2.0, 0.0], "B": [1.0, 3.0]},
|
||||||
|
index=dates[:2],
|
||||||
|
)
|
||||||
|
opens = pd.DataFrame(
|
||||||
|
{"A": [10.0, 10.0, 15.0, 15.0], "B": [20.0, 20.0, 20.0, 21.0]},
|
||||||
|
index=dates,
|
||||||
|
)
|
||||||
|
closes = pd.DataFrame(
|
||||||
|
{"A": [10.0, 12.0, 15.0, 15.0], "B": [20.0, 20.0, 18.0, 21.0]},
|
||||||
|
index=dates,
|
||||||
|
)
|
||||||
|
return run_factor_backtest_research(
|
||||||
|
scores,
|
||||||
|
opens,
|
||||||
|
closes,
|
||||||
|
top_k=1,
|
||||||
|
execution_price_field="open",
|
||||||
|
valuation_price_field="close",
|
||||||
|
initial_cash=1_000.0,
|
||||||
|
config=ExecutionConfig(
|
||||||
|
commission_bps=0,
|
||||||
|
stamp_tax_bps=0,
|
||||||
|
slippage_bps=0,
|
||||||
|
min_trade_amount=0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build(
|
||||||
|
result: FactorBacktestResult,
|
||||||
|
*,
|
||||||
|
parameters: dict[str, object] | None = None,
|
||||||
|
risk_snapshots: dict[date, CovarianceSnapshot] | None = None,
|
||||||
|
) -> ResearchRunArtifact:
|
||||||
|
benchmark = pd.Series(
|
||||||
|
[0.0, 0.01, -0.01, 0.02],
|
||||||
|
index=result.returns.index,
|
||||||
|
name="benchmark_return",
|
||||||
|
)
|
||||||
|
return build_research_run_artifact(
|
||||||
|
result,
|
||||||
|
run_id="run-20260105-a",
|
||||||
|
strategy_id="alpha-top1",
|
||||||
|
strategy_name="Alpha Top 1",
|
||||||
|
strategy_version="1.0.0",
|
||||||
|
engine_version="1.2.0",
|
||||||
|
code_revision="3b1ad07",
|
||||||
|
data_snapshot_id="qtdb-pro-20260108-v1",
|
||||||
|
calendar="CN-A",
|
||||||
|
timezone="Asia/Shanghai",
|
||||||
|
started_at="2026-01-08T10:00:00+08:00",
|
||||||
|
finished_at="2026-01-08T10:01:00+08:00",
|
||||||
|
parameters=parameters or {"top_k": 1, "lag_sessions": 1},
|
||||||
|
benchmark_id="000300.SH",
|
||||||
|
benchmark_returns=benchmark,
|
||||||
|
risk_snapshots=risk_snapshots,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_artifact_projects_versioned_queryable_fact_tables() -> None:
|
||||||
|
result = _backtest_result()
|
||||||
|
|
||||||
|
artifact = _build(result)
|
||||||
|
|
||||||
|
assert artifact.schema_version == RESEARCH_ARTIFACT_SCHEMA_VERSION
|
||||||
|
assert artifact.run.loc[0, "run_id"] == "run-20260105-a"
|
||||||
|
assert artifact.run.loc[0, "benchmark_alignment_policy"] == "exact_session_index"
|
||||||
|
assert artifact.nav["run_id"].unique().tolist() == ["run-20260105-a"]
|
||||||
|
assert artifact.nav["pnl_pct"].tolist() == pytest.approx(result.returns.tolist())
|
||||||
|
assert artifact.nav["benchmark_return"].tolist() == pytest.approx(
|
||||||
|
[0.0, 0.01, -0.01, 0.02]
|
||||||
|
)
|
||||||
|
assert artifact.signals.columns.tolist() == [
|
||||||
|
"run_id",
|
||||||
|
"signal_date",
|
||||||
|
"execution_date",
|
||||||
|
"asset_id",
|
||||||
|
"factor_score",
|
||||||
|
"target_weight",
|
||||||
|
]
|
||||||
|
first_signal = artifact.signals[
|
||||||
|
artifact.signals["signal_date"] == result.factor_scores.index[0].date()
|
||||||
|
]
|
||||||
|
assert first_signal.set_index("asset_id").loc["A", "factor_score"] == 2.0
|
||||||
|
assert first_signal.set_index("asset_id").loc["A", "target_weight"] == 1.0
|
||||||
|
assert first_signal["execution_date"].unique().tolist() == [
|
||||||
|
result.schedule.signal_to_execution.iloc[0].date()
|
||||||
|
]
|
||||||
|
assert set(artifact.trades["side"]) == {"buy", "sell"}
|
||||||
|
assert artifact.trades["trade_id"].is_unique
|
||||||
|
assert artifact.trades["trade_id"].str.startswith("run-20260105-a:").all()
|
||||||
|
assert artifact.trades["signal_id"].str.startswith("run-20260105-a:signal:").all()
|
||||||
|
assert {"security", "cash"}.issubset(set(artifact.positions["asset_type"]))
|
||||||
|
assert artifact.positions.groupby("trade_date")["weight"].sum().tolist() == pytest.approx(
|
||||||
|
[1.0, 1.0, 1.0, 1.0]
|
||||||
|
)
|
||||||
|
assert set(artifact.attribution.columns) == {
|
||||||
|
"run_id",
|
||||||
|
"trade_date",
|
||||||
|
"asset_id",
|
||||||
|
"overnight",
|
||||||
|
"intraday",
|
||||||
|
"asset_total",
|
||||||
|
}
|
||||||
|
assert artifact.attribution_daily["residual"].abs().max() < 1e-12
|
||||||
|
assert artifact.risk.empty
|
||||||
|
assert artifact.risk.columns.tolist() == [
|
||||||
|
"run_id",
|
||||||
|
"trade_date",
|
||||||
|
"asset_id",
|
||||||
|
"weight",
|
||||||
|
"marginal_risk",
|
||||||
|
"component_risk",
|
||||||
|
"risk_contribution",
|
||||||
|
"covariance_snapshot_id",
|
||||||
|
"covariance_as_of_date",
|
||||||
|
"risk_measure",
|
||||||
|
"return_frequency",
|
||||||
|
"periods_per_year",
|
||||||
|
]
|
||||||
|
assert artifact.performance.loc[0, "n_trades"] == len(artifact.trades)
|
||||||
|
assert artifact.performance.loc[0, "ir"] == pytest.approx(
|
||||||
|
result.benchmark_stats(pd.Series([0.0, 0.01, -0.01, 0.02], index=result.returns.index))[
|
||||||
|
"information_ratio"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert "sortino" in artifact.performance.columns
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_artifact_projects_annualized_risk_from_actual_positions() -> None:
|
||||||
|
result = _backtest_result()
|
||||||
|
trade_date = result.position_weights.index[-1].date()
|
||||||
|
covariance = pd.DataFrame(
|
||||||
|
[[0.0001, 0.00002], [0.00002, 0.0004]],
|
||||||
|
index=["A", "B"],
|
||||||
|
columns=["A", "B"],
|
||||||
|
)
|
||||||
|
snapshot = CovarianceSnapshot(
|
||||||
|
snapshot_id="cov-20260107-v1",
|
||||||
|
as_of_date="2026-01-07",
|
||||||
|
covariance=covariance,
|
||||||
|
return_frequency="1d",
|
||||||
|
periods_per_year=252,
|
||||||
|
)
|
||||||
|
|
||||||
|
artifact = _build(result, risk_snapshots={trade_date: snapshot})
|
||||||
|
|
||||||
|
risk = artifact.risk.set_index("asset_id")
|
||||||
|
expected_weights = result.position_weights.loc[pd.Timestamp(trade_date)]
|
||||||
|
assert artifact.schema_version == "1.1.0"
|
||||||
|
assert risk.index.tolist() == ["A", "B"]
|
||||||
|
assert risk["weight"].tolist() == pytest.approx(expected_weights.tolist())
|
||||||
|
assert risk["covariance_snapshot_id"].unique().tolist() == ["cov-20260107-v1"]
|
||||||
|
assert risk["covariance_as_of_date"].unique().tolist() == [date(2026, 1, 7)]
|
||||||
|
assert risk["risk_measure"].unique().tolist() == ["annualized_volatility"]
|
||||||
|
assert risk["return_frequency"].unique().tolist() == ["1d"]
|
||||||
|
assert risk["periods_per_year"].unique().tolist() == [252]
|
||||||
|
assert risk["component_risk"].sum() == pytest.approx((0.0004 * 252) ** 0.5)
|
||||||
|
assert risk["risk_contribution"].sum() == pytest.approx(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_artifact_rejects_future_or_misaligned_risk_snapshots() -> None:
|
||||||
|
result = _backtest_result()
|
||||||
|
trade_date = result.position_weights.index[-1].date()
|
||||||
|
covariance = pd.DataFrame(
|
||||||
|
[[0.0001, 0.0], [0.0, 0.0004]],
|
||||||
|
index=["A", "B"],
|
||||||
|
columns=["A", "B"],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="must not be after trade date"):
|
||||||
|
_build(
|
||||||
|
result,
|
||||||
|
risk_snapshots={
|
||||||
|
trade_date: CovarianceSnapshot(
|
||||||
|
snapshot_id="future-covariance",
|
||||||
|
as_of_date="2026-01-09",
|
||||||
|
covariance=covariance,
|
||||||
|
return_frequency="1d",
|
||||||
|
periods_per_year=252,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="same asset labels"):
|
||||||
|
_build(
|
||||||
|
result,
|
||||||
|
risk_snapshots={
|
||||||
|
trade_date: CovarianceSnapshot(
|
||||||
|
snapshot_id="incomplete-universe",
|
||||||
|
as_of_date="2026-01-07",
|
||||||
|
covariance=covariance.loc[["B"], ["B"]],
|
||||||
|
return_frequency="1d",
|
||||||
|
periods_per_year=252,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_artifact_serialization_and_hashes_are_deterministic() -> None:
|
||||||
|
result = _backtest_result()
|
||||||
|
first = _build(result, parameters={"top_k": 1, "lag_sessions": 1})
|
||||||
|
second = _build(result, parameters={"lag_sessions": 1, "top_k": 1})
|
||||||
|
|
||||||
|
assert first.run.loc[0, "config_hash"] == second.run.loc[0, "config_hash"]
|
||||||
|
assert first.content_sha256 == second.content_sha256
|
||||||
|
assert first.manifest() == second.manifest()
|
||||||
|
decoded = json.loads(first.canonical_json())
|
||||||
|
assert decoded["schema_version"] == RESEARCH_ARTIFACT_SCHEMA_VERSION
|
||||||
|
assert decoded["tables"]["nav"][0]["trade_date"] == "2026-01-05"
|
||||||
|
|
||||||
|
leaked_copy = first.nav
|
||||||
|
leaked_copy.loc[0, "nav"] = -999.0
|
||||||
|
assert first.nav.loc[0, "nav"] != -999.0
|
||||||
|
assert first.content_sha256 == second.content_sha256
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_artifact_requires_complete_reproducibility_identity() -> None:
|
||||||
|
result = _backtest_result()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="code_revision"):
|
||||||
|
build_research_run_artifact(
|
||||||
|
result,
|
||||||
|
run_id="run-1",
|
||||||
|
strategy_id="alpha-top1",
|
||||||
|
strategy_name="Alpha Top 1",
|
||||||
|
strategy_version="1.0.0",
|
||||||
|
engine_version="1.2.0",
|
||||||
|
code_revision="",
|
||||||
|
data_snapshot_id="snapshot-1",
|
||||||
|
calendar="CN-A",
|
||||||
|
timezone="Asia/Shanghai",
|
||||||
|
started_at="2026-01-08T10:00:00+08:00",
|
||||||
|
finished_at="2026-01-08T10:01:00+08:00",
|
||||||
|
parameters={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_artifact_requires_benchmark_identity_and_returns_together() -> None:
|
||||||
|
result = _backtest_result()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="benchmark_id and benchmark_returns"):
|
||||||
|
build_research_run_artifact(
|
||||||
|
result,
|
||||||
|
run_id="run-1",
|
||||||
|
strategy_id="alpha-top1",
|
||||||
|
strategy_name="Alpha Top 1",
|
||||||
|
strategy_version="1.0.0",
|
||||||
|
engine_version="1.2.0",
|
||||||
|
code_revision="3b1ad07",
|
||||||
|
data_snapshot_id="snapshot-1",
|
||||||
|
calendar="CN-A",
|
||||||
|
timezone="Asia/Shanghai",
|
||||||
|
started_at="2026-01-08T10:00:00+08:00",
|
||||||
|
finished_at="2026-01-08T10:01:00+08:00",
|
||||||
|
parameters={},
|
||||||
|
benchmark_id="000300.SH",
|
||||||
|
)
|
||||||
+168
-3
@@ -7,10 +7,12 @@ import pandas as pd
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from quant_engine.data_adapter import (
|
from quant_engine.data_adapter import (
|
||||||
|
AssetReturnSnapshot,
|
||||||
add_vwap_proxy,
|
add_vwap_proxy,
|
||||||
apply_adj_factor,
|
apply_adj_factor,
|
||||||
load_qtdb_daily,
|
load_qtdb_daily,
|
||||||
long_to_wide,
|
long_to_wide,
|
||||||
|
prepare_asset_return_snapshot,
|
||||||
prepare_execution_inputs,
|
prepare_execution_inputs,
|
||||||
prepare_stock_series,
|
prepare_stock_series,
|
||||||
rename_tushare_columns,
|
rename_tushare_columns,
|
||||||
@@ -298,13 +300,176 @@ def test_prepare_execution_inputs_missing_close_raises() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_prepare_execution_inputs_missing_selected_price_raises() -> None:
|
def test_prepare_execution_inputs_missing_selected_price_raises() -> None:
|
||||||
df = pd.DataFrame(
|
df = pd.DataFrame({"stock_code": ["A"], "trade_date": ["2024-01-01"], "close": [10.0]})
|
||||||
{"stock_code": ["A"], "trade_date": ["2024-01-01"], "close": [10.0]}
|
|
||||||
)
|
|
||||||
with pytest.raises(ValueError, match="缺 open"):
|
with pytest.raises(ValueError, match="缺 open"):
|
||||||
prepare_execution_inputs(df, price_col="open")
|
prepare_execution_inputs(df, price_col="open")
|
||||||
|
|
||||||
|
|
||||||
|
# ── prepare_asset_return_snapshot ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _daily_prices() -> pd.DataFrame:
|
||||||
|
return pd.DataFrame(
|
||||||
|
{
|
||||||
|
"stock_code": ["B", "A", "B", "A", "B", "A"],
|
||||||
|
"trade_date": [
|
||||||
|
"2024-01-02",
|
||||||
|
"2024-01-01",
|
||||||
|
"2024-01-01",
|
||||||
|
"2024-01-03",
|
||||||
|
"2024-01-03",
|
||||||
|
"2024-01-02",
|
||||||
|
],
|
||||||
|
"close": [18.0, 10.0, 20.0, 12.1, 19.8, 11.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_asset_return_snapshot_is_stable_and_immutable_by_interface() -> None:
|
||||||
|
snapshot = prepare_asset_return_snapshot(
|
||||||
|
_daily_prices(),
|
||||||
|
source="qtdb_pro.hq_daily",
|
||||||
|
source_snapshot_id="hq-daily:2024-01-03:v1",
|
||||||
|
adjustment="qfq",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(snapshot, AssetReturnSnapshot)
|
||||||
|
assert snapshot.data_snapshot_id.startswith("asset-returns-v1:")
|
||||||
|
assert snapshot.source == "qtdb_pro.hq_daily"
|
||||||
|
assert snapshot.source_snapshot_id == "hq-daily:2024-01-03:v1"
|
||||||
|
assert snapshot.price_field == "close"
|
||||||
|
assert snapshot.adjustment == "qfq"
|
||||||
|
assert snapshot.return_method == "simple"
|
||||||
|
assert snapshot.start_date.isoformat() == "2024-01-01"
|
||||||
|
assert snapshot.end_date.isoformat() == "2024-01-03"
|
||||||
|
assert snapshot.sessions == 3
|
||||||
|
assert snapshot.assets == ("A", "B")
|
||||||
|
|
||||||
|
expected = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"A": [np.nan, 0.1, 0.1],
|
||||||
|
"B": [np.nan, -0.1, 0.1],
|
||||||
|
},
|
||||||
|
index=pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]),
|
||||||
|
)
|
||||||
|
expected.index.name = "trade_date"
|
||||||
|
expected.columns.name = "stock_code"
|
||||||
|
pd.testing.assert_frame_equal(snapshot.returns, expected)
|
||||||
|
|
||||||
|
exposed = snapshot.returns
|
||||||
|
exposed.iloc[1, 0] = 999.0
|
||||||
|
assert snapshot.returns.iloc[1, 0] == pytest.approx(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_asset_return_snapshot_identity_is_order_independent_and_content_addressed() -> None:
|
||||||
|
kwargs = {
|
||||||
|
"source": "qtdb_pro.hq_daily",
|
||||||
|
"source_snapshot_id": "hq-daily:2024-01-03:v1",
|
||||||
|
"adjustment": "none",
|
||||||
|
}
|
||||||
|
baseline = prepare_asset_return_snapshot(_daily_prices(), **kwargs)
|
||||||
|
shuffled = prepare_asset_return_snapshot(
|
||||||
|
_daily_prices().sample(frac=1.0, random_state=7),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
changed_prices = _daily_prices().copy()
|
||||||
|
changed_prices.loc[changed_prices["close"] == 12.1, "close"] = 12.2
|
||||||
|
changed_content = prepare_asset_return_snapshot(changed_prices, **kwargs)
|
||||||
|
changed_source = prepare_asset_return_snapshot(
|
||||||
|
_daily_prices(),
|
||||||
|
source="qtdb_pro.hq_daily",
|
||||||
|
source_snapshot_id="hq-daily:2024-01-03:v2",
|
||||||
|
adjustment="none",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert shuffled.data_snapshot_id == baseline.data_snapshot_id
|
||||||
|
assert changed_content.data_snapshot_id != baseline.data_snapshot_id
|
||||||
|
assert changed_source.data_snapshot_id != baseline.data_snapshot_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_asset_return_snapshot_does_not_fill_missing_prices() -> None:
|
||||||
|
prices = _daily_prices()
|
||||||
|
prices.loc[
|
||||||
|
(prices["stock_code"] == "A") & (prices["trade_date"] == "2024-01-02"),
|
||||||
|
"close",
|
||||||
|
] = np.nan
|
||||||
|
|
||||||
|
snapshot = prepare_asset_return_snapshot(
|
||||||
|
prices,
|
||||||
|
source="qtdb_pro.hq_daily",
|
||||||
|
source_snapshot_id="hq-daily:missing-middle",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert pd.isna(snapshot.returns.loc[pd.Timestamp("2024-01-02"), "A"])
|
||||||
|
assert pd.isna(snapshot.returns.loc[pd.Timestamp("2024-01-03"), "A"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_asset_return_snapshot_rejects_duplicate_sessions() -> None:
|
||||||
|
duplicate = pd.concat([_daily_prices(), _daily_prices().iloc[[0]]], ignore_index=True)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="duplicate"):
|
||||||
|
prepare_asset_return_snapshot(
|
||||||
|
duplicate,
|
||||||
|
source="qtdb_pro.hq_daily",
|
||||||
|
source_snapshot_id="hq-daily:duplicate",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("invalid_price", [0.0, -1.0, np.inf])
|
||||||
|
def test_prepare_asset_return_snapshot_rejects_invalid_prices(invalid_price: float) -> None:
|
||||||
|
prices = _daily_prices()
|
||||||
|
prices.loc[0, "close"] = invalid_price
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="positive finite"):
|
||||||
|
prepare_asset_return_snapshot(
|
||||||
|
prices,
|
||||||
|
source="qtdb_pro.hq_daily",
|
||||||
|
source_snapshot_id="hq-daily:invalid-price",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("source", "source_snapshot_id", "adjustment"),
|
||||||
|
[
|
||||||
|
("", "source-1", "none"),
|
||||||
|
("qtdb_pro.hq_daily", "", "none"),
|
||||||
|
("qtdb_pro.hq_daily", "source-1", ""),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_prepare_asset_return_snapshot_requires_explicit_identity_semantics(
|
||||||
|
source: str,
|
||||||
|
source_snapshot_id: str,
|
||||||
|
adjustment: str,
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(ValueError, match="must be non-empty"):
|
||||||
|
prepare_asset_return_snapshot(
|
||||||
|
_daily_prices(),
|
||||||
|
source=source,
|
||||||
|
source_snapshot_id=source_snapshot_id,
|
||||||
|
adjustment=adjustment,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_asset_return_snapshot_feeds_reproducible_covariance_lineage() -> None:
|
||||||
|
from quant_engine.risk import estimate_covariance_snapshot
|
||||||
|
|
||||||
|
market_snapshot = prepare_asset_return_snapshot(
|
||||||
|
_daily_prices(),
|
||||||
|
source="qtdb_pro.hq_daily",
|
||||||
|
source_snapshot_id="hq-daily:2024-01-03:v1",
|
||||||
|
)
|
||||||
|
covariance_snapshot = estimate_covariance_snapshot(
|
||||||
|
market_snapshot.returns,
|
||||||
|
as_of_date=market_snapshot.end_date,
|
||||||
|
lookback_sessions=3,
|
||||||
|
min_observations=2,
|
||||||
|
data_snapshot_id=market_snapshot.data_snapshot_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert covariance_snapshot.data_snapshot_id == market_snapshot.data_snapshot_id
|
||||||
|
assert covariance_snapshot.snapshot_id.startswith("sample-cov-v1:")
|
||||||
|
|
||||||
|
|
||||||
# ── 端到端:长表 → 适配 → alpha158 + execution ──────────────
|
# ── 端到端:长表 → 适配 → alpha158 + execution ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+23
-1
@@ -14,6 +14,7 @@ from quant_engine.metrics import (
|
|||||||
calmar_ratio,
|
calmar_ratio,
|
||||||
max_drawdown,
|
max_drawdown,
|
||||||
sharpe_ratio,
|
sharpe_ratio,
|
||||||
|
sortino_ratio,
|
||||||
summary,
|
summary,
|
||||||
win_rate,
|
win_rate,
|
||||||
)
|
)
|
||||||
@@ -48,9 +49,22 @@ def test_zero_volatility_metrics_return_zero() -> None:
|
|||||||
returns = pd.Series([0.0, 0.0, 0.0])
|
returns = pd.Series([0.0, 0.0, 0.0])
|
||||||
|
|
||||||
assert sharpe_ratio(returns) == 0.0
|
assert sharpe_ratio(returns) == 0.0
|
||||||
|
assert sortino_ratio(returns) == 0.0
|
||||||
assert calmar_ratio(returns) == 0.0
|
assert calmar_ratio(returns) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sortino_ratio_uses_all_sessions_for_downside_deviation() -> None:
|
||||||
|
returns = pd.Series([0.02, -0.01, 0.0, -0.03])
|
||||||
|
downside = np.minimum(returns.to_numpy(), 0.0)
|
||||||
|
downside_deviation = np.sqrt(np.mean(np.square(downside))) * np.sqrt(
|
||||||
|
TRADING_DAYS_PER_YEAR
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sortino_ratio(returns) == pytest.approx(
|
||||||
|
annualized_return(returns) / downside_deviation
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_max_drawdown_includes_loss_from_initial_capital() -> None:
|
def test_max_drawdown_includes_loss_from_initial_capital() -> None:
|
||||||
returns = pd.Series([-0.20, 0.0])
|
returns = pd.Series([-0.20, 0.0])
|
||||||
|
|
||||||
@@ -80,7 +94,15 @@ def test_summary_aliases_match_canonical_fields() -> None:
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"metric",
|
"metric",
|
||||||
[annualized_return, annualized_volatility, sharpe_ratio, max_drawdown, calmar_ratio, win_rate],
|
[
|
||||||
|
annualized_return,
|
||||||
|
annualized_volatility,
|
||||||
|
sharpe_ratio,
|
||||||
|
sortino_ratio,
|
||||||
|
max_drawdown,
|
||||||
|
calmar_ratio,
|
||||||
|
win_rate,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
def test_metrics_reject_non_series_input(metric) -> None:
|
def test_metrics_reject_non_series_input(metric) -> None:
|
||||||
with pytest.raises(TypeError, match=r"expected pd\.Series"):
|
with pytest.raises(TypeError, match=r"expected pd\.Series"):
|
||||||
|
|||||||
@@ -8,13 +8,164 @@ import pytest
|
|||||||
|
|
||||||
from quant_engine.risk import (
|
from quant_engine.risk import (
|
||||||
ComponentRiskResult,
|
ComponentRiskResult,
|
||||||
|
CovarianceSnapshot,
|
||||||
component_var,
|
component_var,
|
||||||
|
estimate_covariance_snapshot,
|
||||||
labeled_component_risk,
|
labeled_component_risk,
|
||||||
marginal_risk_contribution,
|
marginal_risk_contribution,
|
||||||
risk_contribution,
|
risk_contribution,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_covariance_snapshot_is_complete_case_and_reproducible() -> None:
|
||||||
|
dates = pd.date_range("2026-01-05", periods=6, freq="B")
|
||||||
|
returns = pd.DataFrame(
|
||||||
|
{
|
||||||
|
"A": [0.01, 0.02, 0.03, 0.04, 0.05, 99.0],
|
||||||
|
"B": [0.02, 0.01, np.nan, 0.03, 0.04, -99.0],
|
||||||
|
},
|
||||||
|
index=dates,
|
||||||
|
)
|
||||||
|
as_of = dates[4]
|
||||||
|
|
||||||
|
snapshot = estimate_covariance_snapshot(
|
||||||
|
returns,
|
||||||
|
as_of_date=as_of,
|
||||||
|
lookback_sessions=4,
|
||||||
|
min_observations=3,
|
||||||
|
data_snapshot_id="market-returns-20260109-v1",
|
||||||
|
return_frequency="1d",
|
||||||
|
periods_per_year=252,
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_window = returns.loc[:as_of].tail(4)
|
||||||
|
expected = expected_window.dropna(how="any").cov()
|
||||||
|
pd.testing.assert_frame_equal(snapshot.covariance, expected)
|
||||||
|
assert snapshot.snapshot_id.startswith("sample-cov-v1:")
|
||||||
|
assert snapshot.as_of_date == as_of.date()
|
||||||
|
assert snapshot.method == "sample"
|
||||||
|
assert snapshot.window_start_date == expected_window.index[0].date()
|
||||||
|
assert snapshot.window_end_date == as_of.date()
|
||||||
|
assert snapshot.observations == 3
|
||||||
|
assert snapshot.lookback_sessions == 4
|
||||||
|
assert snapshot.missing_policy == "complete_case"
|
||||||
|
assert snapshot.data_snapshot_id == "market-returns-20260109-v1"
|
||||||
|
assert len(snapshot.input_sha256) == 64
|
||||||
|
|
||||||
|
future_changed = returns.copy()
|
||||||
|
future_changed.loc[dates[-1], :] = [1_000_000.0, -1_000_000.0]
|
||||||
|
repeated = estimate_covariance_snapshot(
|
||||||
|
future_changed,
|
||||||
|
as_of_date=as_of,
|
||||||
|
lookback_sessions=4,
|
||||||
|
min_observations=3,
|
||||||
|
data_snapshot_id="market-returns-20260109-v1",
|
||||||
|
return_frequency="1d",
|
||||||
|
periods_per_year=252,
|
||||||
|
)
|
||||||
|
assert repeated.snapshot_id == snapshot.snapshot_id
|
||||||
|
pd.testing.assert_frame_equal(repeated.covariance, snapshot.covariance)
|
||||||
|
|
||||||
|
|
||||||
|
def test_covariance_snapshot_identity_captures_data_and_estimator_contract() -> None:
|
||||||
|
dates = pd.date_range("2026-01-05", periods=4, freq="B")
|
||||||
|
returns = pd.DataFrame(
|
||||||
|
{"A": [0.01, 0.02, -0.01, 0.03], "B": [0.02, -0.01, 0.01, 0.04]},
|
||||||
|
index=dates,
|
||||||
|
)
|
||||||
|
base = estimate_covariance_snapshot(
|
||||||
|
returns,
|
||||||
|
as_of_date=dates[-1],
|
||||||
|
lookback_sessions=4,
|
||||||
|
min_observations=3,
|
||||||
|
data_snapshot_id="snapshot-a",
|
||||||
|
)
|
||||||
|
different_source = estimate_covariance_snapshot(
|
||||||
|
returns,
|
||||||
|
as_of_date=dates[-1],
|
||||||
|
lookback_sessions=4,
|
||||||
|
min_observations=3,
|
||||||
|
data_snapshot_id="snapshot-b",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert base.snapshot_id != different_source.snapshot_id
|
||||||
|
assert base.covariance.equals(different_source.covariance)
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_covariance_snapshot_rejects_ambiguous_or_insufficient_history() -> None:
|
||||||
|
dates = pd.date_range("2026-01-05", periods=4, freq="B")
|
||||||
|
returns = pd.DataFrame(
|
||||||
|
{"A": [0.01, np.nan, 0.03, 0.04], "B": [0.02, 0.01, np.nan, 0.03]},
|
||||||
|
index=dates,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="complete observations"):
|
||||||
|
estimate_covariance_snapshot(
|
||||||
|
returns,
|
||||||
|
as_of_date=dates[-1],
|
||||||
|
lookback_sessions=4,
|
||||||
|
min_observations=3,
|
||||||
|
data_snapshot_id="snapshot-a",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="strictly increasing"):
|
||||||
|
estimate_covariance_snapshot(
|
||||||
|
returns.iloc[::-1],
|
||||||
|
as_of_date=dates[-1],
|
||||||
|
lookback_sessions=4,
|
||||||
|
min_observations=2,
|
||||||
|
data_snapshot_id="snapshot-a",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_covariance_snapshot_is_validated_and_immutable_by_interface() -> None:
|
||||||
|
covariance = pd.DataFrame(
|
||||||
|
[[0.04, 0.01], [0.01, 0.09]],
|
||||||
|
index=["A", "B"],
|
||||||
|
columns=["A", "B"],
|
||||||
|
)
|
||||||
|
snapshot = CovarianceSnapshot(
|
||||||
|
snapshot_id="cov-20260107-v1",
|
||||||
|
as_of_date="2026-01-07",
|
||||||
|
covariance=covariance,
|
||||||
|
return_frequency="1d",
|
||||||
|
periods_per_year=252,
|
||||||
|
)
|
||||||
|
|
||||||
|
covariance.loc["A", "A"] = 999.0
|
||||||
|
leaked_copy = snapshot.covariance
|
||||||
|
leaked_copy.loc["B", "B"] = 999.0
|
||||||
|
|
||||||
|
assert snapshot.as_of_date == pd.Timestamp("2026-01-07").date()
|
||||||
|
assert snapshot.covariance.loc["A", "A"] == pytest.approx(0.04)
|
||||||
|
assert snapshot.covariance.loc["B", "B"] == pytest.approx(0.09)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("kwargs", "message"),
|
||||||
|
[
|
||||||
|
({"snapshot_id": ""}, "snapshot_id"),
|
||||||
|
({"return_frequency": ""}, "return_frequency"),
|
||||||
|
({"periods_per_year": 0}, "periods_per_year"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_covariance_snapshot_rejects_incomplete_identity(
|
||||||
|
kwargs: dict[str, object],
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
values: dict[str, object] = {
|
||||||
|
"snapshot_id": "cov-20260107-v1",
|
||||||
|
"as_of_date": "2026-01-07",
|
||||||
|
"covariance": pd.DataFrame([[0.04]], index=["A"], columns=["A"]),
|
||||||
|
"return_frequency": "1d",
|
||||||
|
"periods_per_year": 252,
|
||||||
|
}
|
||||||
|
values.update(kwargs)
|
||||||
|
|
||||||
|
with pytest.raises((TypeError, ValueError), match=message):
|
||||||
|
CovarianceSnapshot(**values)
|
||||||
|
|
||||||
|
|
||||||
def test_risk_contribution_sums_to_one_for_positive_portfolio_variance() -> None:
|
def test_risk_contribution_sums_to_one_for_positive_portfolio_variance() -> None:
|
||||||
weights = np.array([0.5, 0.5])
|
weights = np.array([0.5, 0.5])
|
||||||
covariance = np.diag([1.0, 4.0])
|
covariance = np.diag([1.0, 4.0])
|
||||||
|
|||||||
Reference in New Issue
Block a user