Compare commits

...
6 Commits
7 changed files with 800 additions and 1 deletions
+23
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,28 @@ 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
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,
)
print(artifact.manifest())
# run_weight_backtest 是低层算子:只接受收益区间开始前已经生效的持仓权重。
# 不要把 signal-date 的 factor_scores/decision_weights 直接传给它。
backtest = run_weight_backtest(
+11
View File
@@ -16,6 +16,17 @@
当前核心不新增依赖。逐日收益归因必须从实际换仓前后持仓、成交记录、执行价和
收盘估值推导;因子分数与目标权重只是意图,不能作为成交后归因事实源。
## 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。
## hikyuu 的定位
[hikyuu](https://github.com/fasiondog/hikyuu) 的 SG / MM / CN / PG 部件化思想、
@@ -0,0 +1,37 @@
# 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;
- reserved risk snapshot table;
- 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 --cov=src --cov-report=term-missing`: 520 passed,9 个既有 SciPy warning;
- total coverage 91%,`artifact.py` 91%;
- `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。
## Next action
在 `research_results` 新建独立分支,实现只接受 `ResearchRunArtifact.table_frames()` 的
ClickHouse / artifact-store adapter;先以 mock writer 做契约测试,不接触真实数据库。
+491
View File
@@ -0,0 +1,491 @@
"""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
RESEARCH_ARTIFACT_SCHEMA_VERSION = "1.0.0"
RISK_COLUMNS = [
"run_id",
"trade_date",
"asset_id",
"weight",
"marginal_risk",
"component_risk",
"risk_contribution",
"covariance_snapshot_id",
]
__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["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 _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,
) -> 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=pd.DataFrame(columns=RISK_COLUMNS),
_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),
+200
View File
@@ -0,0 +1,200 @@
"""Stable research-run artifact contracts for downstream persistence."""
from __future__ import annotations
import json
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
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,
) -> 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,
)
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 {"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",
]
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_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"):