Compare commits

...
9 changed files with 1093 additions and 1 deletions
+34
View File
@@ -25,6 +25,7 @@
- `backtest` — weight-based 多日仿真(rebalance_table / compute_nav / compare_to_benchmark)
- `portfolio_construction` — 多期因子分数 → Top-K → 等权目标权重表
- `research_pipeline` — 因子日 → 下一真实交易日 → 显式执行价 → 日末估值 → 成本后绩效(防前视编排)
- `artifact` — 版本化、确定性、存储中立的完整 research run 事实表与 manifest
- `attribution` — 基于实际成交后持仓的隔夜 / 日内 / 交易成本逐日收益归因与闭合审计
- `metrics` — 绝对绩效 + 严格日期对齐的 TE / IR / alpha / beta 基准相对绩效
- `factor_library` — 通用方法(turnover / winsorize / IC / OLS / jb_test)
@@ -136,6 +137,39 @@ print(attribution.residual) # 应接近 0;否则说明贡献未闭合到账
# benchmark_returns 必须与成本后 factor_backtest.returns 使用完全相同的日期索引。
print(factor_backtest.benchmark_stats(benchmark_returns))
# 下游稳定交付:显式提供代码版本、数据快照和时区,不在核心层写数据库。
from quant_engine.artifact import build_research_run_artifact
from quant_engine.risk import CovarianceSnapshot
risk_date = factor_backtest.position_weights.index[-1].date()
risk_snapshot = CovarianceSnapshot(
snapshot_id="<covariance-snapshot-id>",
as_of_date=risk_date,
covariance=daily_return_covariance,
return_frequency="1d",
periods_per_year=252,
)
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="<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 是低层算子:只接受收益区间开始前已经生效的持仓权重。
# 不要把 signal-date 的 factor_scores/decision_weights 直接传给它。
backtest = run_weight_backtest(
+17
View File
@@ -16,6 +16,23 @@
当前核心不新增依赖。逐日收益归因必须从实际换仓前后持仓、成交记录、执行价和
收盘估值推导;因子分数与目标权重只是意图,不能作为成交后归因事实源。
## 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。未来日期、资产
标签不完整和零方差组合都直接失败,不以默认值伪造结果。
## hikyuu 的定位
[hikyuu](https://github.com/fasiondog/hikyuu) 的 SG / MM / CN / PG 部件化思想、
@@ -0,0 +1,45 @@
# 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`: 526 passed,9 个既有 SciPy warning;
- risk/artifact focused coverage 87.85%;
- `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。
## Current risk contract
- artifact schema:`1.1.0`;
- `CovarianceSnapshot` 对输入矩阵深拷贝并显式记录截至日、频率和年化期数;
- `risk_snapshots` 按研究交易日映射,可只生成需要的风险观察日;
- 使用成交后实际持仓,不包含现金风险资产;协方差资产标签必须与研究资产全集一致;
- `covariance_as_of_date` 不得晚于 `trade_date`;无正组合方差时拒绝产物。
## Next action
保持 Draft PR #5,不绕过堆叠顺序合并;下游 `research_results` / `research_platform`
已在各自 Draft 分支兼容 1.0.0 / 1.1.0,下一阶段以真实数据适配器提供可复现协方差快照。
+577
View File
@@ -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),
)
+15
View File
@@ -65,6 +65,20 @@ def sharpe_ratio(r: pd.Series, rf: float = 0.0) -> float:
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:
"""最大回撤(负数)。例如 -0.2 表示最大亏 20%。"""
r = _clean(r)
@@ -120,6 +134,7 @@ def summary(r: pd.Series, rf: float = 0.0) -> Mapping[str, float]:
"ann_return": ann_ret,
"ann_volatility": ann_vol,
"sharpe": sharpe_ratio(r, rf),
"sortino": sortino_ratio(r, rf),
"max_drawdown": mdd,
"calmar": calmar_ratio(r),
"win_rate": win_rate(r),
+52
View File
@@ -6,6 +6,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from typing import Any
import numpy as np
@@ -14,6 +15,7 @@ from numpy.typing import NDArray
__all__ = [
"ComponentRiskResult",
"CovarianceSnapshot",
"component_var",
"labeled_component_risk",
"marginal_risk_contribution",
@@ -21,6 +23,56 @@ __all__ = [
]
@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
def __init__(
self,
*,
snapshot_id: str,
as_of_date: str | date | pd.Timestamp,
covariance: pd.DataFrame,
return_frequency: str,
periods_per_year: int,
) -> 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")
try:
normalized_as_of = pd.Timestamp(as_of_date)
except (TypeError, ValueError) as error:
raise ValueError("as_of_date must be a valid date") from error
if pd.isna(normalized_as_of):
raise ValueError("as_of_date must be a valid date")
object.__setattr__(self, "snapshot_id", snapshot_id.strip())
object.__setattr__(self, "as_of_date", normalized_as_of.date())
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)
@property
def covariance(self) -> pd.DataFrame:
"""Return an isolated copy so callers cannot mutate the snapshot."""
return self._covariance.copy(deep=True)
@dataclass(frozen=True, slots=True, eq=False)
class ComponentRiskResult:
"""Label-preserving Euler decomposition of portfolio volatility."""
+281
View File
@@ -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",
)
+23 -1
View File
@@ -14,6 +14,7 @@ from quant_engine.metrics import (
calmar_ratio,
max_drawdown,
sharpe_ratio,
sortino_ratio,
summary,
win_rate,
)
@@ -48,9 +49,22 @@ def test_zero_volatility_metrics_return_zero() -> None:
returns = pd.Series([0.0, 0.0, 0.0])
assert sharpe_ratio(returns) == 0.0
assert sortino_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:
returns = pd.Series([-0.20, 0.0])
@@ -80,7 +94,15 @@ def test_summary_aliases_match_canonical_fields() -> None:
@pytest.mark.parametrize(
"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:
with pytest.raises(TypeError, match=r"expected pd\.Series"):
+49
View File
@@ -8,6 +8,7 @@ import pytest
from quant_engine.risk import (
ComponentRiskResult,
CovarianceSnapshot,
component_var,
labeled_component_risk,
marginal_risk_contribution,
@@ -15,6 +16,54 @@ from quant_engine.risk import (
)
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:
weights = np.array([0.5, 0.5])
covariance = np.diag([1.0, 4.0])