diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 4c9fe42..c45eb26 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -16,6 +16,8 @@ permissions: jobs: lite: runs-on: ubuntu-latest + env: + UV_PYTHON_DOWNLOADS: never steps: - uses: actions/checkout@524e936cd9e579adf00e308bfdf971aebc7de09e with: @@ -26,7 +28,18 @@ jobs: if git ls-files .DS_Store | grep -q .; then echo "跟踪 .DS_Store"; exit 1; fi if git grep -n -I -E 'sk-[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|xox[baprs]-[A-Za-z0-9-]{10,}' HEAD | grep -q .; then echo "检出疑似凭证"; exit 1; fi echo "Gitea 合规校验通过" + + - name: 验证并同步共享运行时 + run: | + test "$(python3 --version)" = "Python 3.13.15" + test "$(uv --version | cut -d' ' -f1-2)" = "uv 0.12.3" + uv sync --locked --extra dev + uv run --locked --no-sync python -c 'import sys; assert sys.version_info[:2] == (3, 13)' + - name: 架构模块契约测试 - run: python3 tests/governance/test_module_spec.py + run: | + uv run --locked --no-sync python tests/governance/test_module_spec.py + uv run --locked --no-sync python tests/governance/test_ci_contract.py + - name: Syntax check - run: git ls-files -z '*.py' | xargs -0 python3 -m py_compile + run: git ls-files -z '*.py' | xargs -0 uv run --locked --no-sync python -m py_compile diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/README.md b/README.md index 2a3cf2d..3ac93af 100644 --- a/README.md +++ b/README.md @@ -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,46 @@ 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.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="", + 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="", + 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 是低层算子:只接受收益区间开始前已经生效的持仓权重。 # 不要把 signal-date 的 factor_scores/decision_weights 直接传给它。 backtest = run_weight_backtest( diff --git a/ci-profile.yml b/ci-profile.yml new file mode 100644 index 0000000..9217a6f --- /dev/null +++ b/ci-profile.yml @@ -0,0 +1,9 @@ +profile: lite +runtime_contract: v1 +language: python +python_version: "3.13" +python_manager: uv +python_root: "." +local_test_command: "python3 tests/governance/test_module_spec.py" +requires_database: false +integration_profile: none diff --git a/docs/OPEN_SOURCE_REFERENCES.md b/docs/OPEN_SOURCE_REFERENCES.md index fd2a6fa..645184c 100644 --- a/docs/OPEN_SOURCE_REFERENCES.md +++ b/docs/OPEN_SOURCE_REFERENCES.md @@ -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](https://github.com/fasiondog/hikyuu) 的 SG / MM / CN / PG 部件化思想、 diff --git a/docs/handoff/2026-08-21-research-artifact-contract.md b/docs/handoff/2026-08-21-research-artifact-contract.md new file mode 100644 index 0000000..9ec6a8f --- /dev/null +++ b/docs/handoff/2026-08-21-research-artifact-contract.md @@ -0,0 +1,57 @@ +# 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:`,并把同一 ID 传给 covariance 与 run artifact。 +- artifact builder fail closed:每个 `CovarianceSnapshot.data_snapshot_id` 必须与 run 级 + `data_snapshot_id` 完全一致,禁止把其他行情快照的风险分解静默发布到当前研究运行。 +- shrinkage 适配器本轮不实现:scikit-learn 尚非声明依赖,未来只允许薄适配 + `LedoitWolf` / `OAS`,不复制公式、不依赖环境偶然安装状态。 + +## Next action + +保持 Draft PR #5,不绕过堆叠顺序合并;下游 `research_results` / `research_platform` +继续在现有 Draft 分支消费同一数据 lineage。下一阶段优先把 ingestion snapshot ID 从 +真实 ELT 元数据接入调用方,再在依赖治理通过后单独交付可选 shrinkage adapter。 diff --git a/pyproject.toml b/pyproject.toml index c9939b6..edd054d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "quant_engine" version = "0.1.0" description = "量化研究引擎 —— alpha 因子库 + 执行仿真 + 技术指标 + 数据适配 + 回测工具(v1.2.0 从 research_results 抽出)" readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.13,<3.14" license = { text = "MIT" } authors = [ { name = "researchhub team" }, @@ -39,7 +39,7 @@ where = ["src"] [tool.ruff] line-length = 100 -target-version = "py311" +target-version = "py313" [tool.ruff.lint] select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "PT", "RUF"] @@ -54,10 +54,13 @@ ignore = [ ] [tool.mypy] -python_version = "3.11" +python_version = "3.13" strict = true ignore_missing_imports = true +[tool.uv] +index-url = "https://mirrors.cloud.tencent.com/pypi/simple" + [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-v --tb=short" diff --git a/src/quant_engine/artifact.py b/src/quant_engine/artifact.py new file mode 100644 index 0000000..2d2b8f8 --- /dev/null +++ b/src/quant_engine/artifact.py @@ -0,0 +1,580 @@ +"""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, + data_snapshot_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}" + ) + if snapshot.data_snapshot_id != data_snapshot_id: + raise ValueError("covariance snapshot data lineage differs from research run") + 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, normalized_snapshot, risk_snapshots), + _performance=_build_performance(result, normalized_run_id, benchmark_returns), + ) diff --git a/src/quant_engine/data_adapter.py b/src/quant_engine/data_adapter.py index 041b6c3..28b3496 100644 --- a/src/quant_engine/data_adapter.py +++ b/src/quant_engine/data_adapter.py @@ -6,22 +6,27 @@ - execution.py 需要**宽表**(date × stock_code)prices / volumes - Tushare 字段命名:`ts_code / vol(手) / amount(千元) / pct_chg`,且**无 vwap 字段** -本模块提供 6 个纯函数,让新模块直接吃 qtdb_pro 真实数据: +本模块提供可组合的数据适配函数,让新模块直接吃 qtdb_pro 真实数据: 1. `long_to_wide()` — 长表 → 宽表(date × stock_code) 2. `wide_to_long()` — 宽表 → 长表 3. `rename_tushare_columns()` — 列名映射(ts_code→stock_code, vol→volume 等) 4. `add_vwap_proxy()` — vwap 代理(Tushare 无 vwap 字段) 5. `apply_adj_factor()` — 复权(hq_daily × hq_adj_factor 前复权) 6. `prepare_stock_series()` — 单股提取(alpha_factors 输入) -7. `prepare_execution_inputs()` — execution 输入(prices + volumes 宽表) -8. `load_qtdb_daily()` — 便捷加载(qtdb_pro.hq_daily + 可选复权) +7. `prepare_asset_return_snapshot()` — 带稳定 lineage 的资产日收益 +8. `prepare_execution_inputs()` — execution 输入(prices + volumes 宽表) +9. `load_qtdb_daily()` — 便捷加载(qtdb_pro.hq_daily + 可选复权) 全部纯 pandas/numpy,零新依赖,mypy strict 兼容。 """ from __future__ import annotations +import hashlib +import json from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import date from typing import Any import numpy as np @@ -32,12 +37,14 @@ from quant_engine.logging import get_logger logger = get_logger(__name__) __all__ = [ + "AssetReturnSnapshot", "long_to_wide", "wide_to_long", "rename_tushare_columns", "add_vwap_proxy", "apply_adj_factor", "prepare_stock_series", + "prepare_asset_return_snapshot", "prepare_execution_inputs", "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(" 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( df: pd.DataFrame, stock_col: str = "stock_code", diff --git a/src/quant_engine/metrics.py b/src/quant_engine/metrics.py index 2142500..3557214 100644 --- a/src/quant_engine/metrics.py +++ b/src/quant_engine/metrics.py @@ -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), diff --git a/src/quant_engine/risk.py b/src/quant_engine/risk.py index d2328fa..748ad36 100644 --- a/src/quant_engine/risk.py +++ b/src/quant_engine/risk.py @@ -5,7 +5,10 @@ from __future__ import annotations +import hashlib +import json from dataclasses import dataclass +from datetime import date from typing import Any import numpy as np @@ -14,13 +17,260 @@ from numpy.typing import NDArray __all__ = [ "ComponentRiskResult", + "CovarianceSnapshot", "component_var", + "estimate_covariance_snapshot", "labeled_component_risk", "marginal_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(" 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(" None: + def test_ci_is_one_locked_shared_runtime_lite_gate(self) -> None: workflow = (ROOT / ".gitea/workflows/ci.yml").read_text(encoding="utf-8") jobs = workflow.split("jobs:", 1)[1] self.assertEqual(re.findall(r"(?m)^ ([a-z][a-z0-9_-]*):\s*$", jobs), ["lite"]) self.assertIn("actions/checkout@524e936cd9e579adf00e308bfdf971aebc7de09e", workflow) self.assertIn("persist-credentials: false", workflow) - self.assertIn("python3 tests/governance/test_module_spec.py", workflow) - for forbidden in ("setup-python", "pip ", "curl ", "wget ", "docker pull"): + self.assertIn("UV_PYTHON_DOWNLOADS: never", workflow) + self.assertIn('test "$(python3 --version)" = "Python 3.13.15"', workflow) + self.assertIn( + 'test "$(uv --version | cut -d\' \' -f1-2)" = "uv 0.12.3"', + workflow, + ) + self.assertIn("uv sync --locked --extra dev", workflow) + self.assertIn("uv run --locked --no-sync python", workflow) + self.assertIn("tests/governance/test_ci_contract.py", workflow) + for forbidden in ("setup-python", "setup-uv", "pip ", "curl ", "wget ", "docker pull"): self.assertNotIn(forbidden, workflow) diff --git a/tests/test_artifact.py b/tests/test_artifact.py new file mode 100644 index 0000000..d208d6c --- /dev/null +++ b/tests/test_artifact.py @@ -0,0 +1,309 @@ +"""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, + data_snapshot_id="qtdb-pro-20260108-v1", + ) + + 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_risk_from_a_different_data_snapshot() -> 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="data lineage differs"): + _build( + result, + risk_snapshots={ + trade_date: CovarianceSnapshot( + snapshot_id="foreign-covariance", + as_of_date="2026-01-07", + covariance=covariance, + return_frequency="1d", + periods_per_year=252, + data_snapshot_id="different-market-snapshot", + ) + }, + ) + + +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, + data_snapshot_id="qtdb-pro-20260108-v1", + ) + }, + ) + + 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, + data_snapshot_id="qtdb-pro-20260108-v1", + ) + }, + ) + + +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", + ) diff --git a/tests/test_data_adapter.py b/tests/test_data_adapter.py index df21754..87b5af5 100644 --- a/tests/test_data_adapter.py +++ b/tests/test_data_adapter.py @@ -7,10 +7,12 @@ import pandas as pd import pytest from quant_engine.data_adapter import ( + AssetReturnSnapshot, add_vwap_proxy, apply_adj_factor, load_qtdb_daily, long_to_wide, + prepare_asset_return_snapshot, prepare_execution_inputs, prepare_stock_series, 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: - df = pd.DataFrame( - {"stock_code": ["A"], "trade_date": ["2024-01-01"], "close": [10.0]} - ) + df = pd.DataFrame({"stock_code": ["A"], "trade_date": ["2024-01-01"], "close": [10.0]}) with pytest.raises(ValueError, match="缺 open"): prepare_execution_inputs(df, price_col="open") +# ── 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 ────────────── diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 44a296d..cb56d11 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -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"): diff --git a/tests/test_risk.py b/tests/test_risk.py index 2de4cab..8160e6e 100644 --- a/tests/test_risk.py +++ b/tests/test_risk.py @@ -8,13 +8,164 @@ import pytest from quant_engine.risk import ( ComponentRiskResult, + CovarianceSnapshot, component_var, + estimate_covariance_snapshot, labeled_component_risk, marginal_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: weights = np.array([0.5, 0.5]) covariance = np.diag([1.0, 4.0]) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2f650bd --- /dev/null +++ b/uv.lock @@ -0,0 +1,408 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" +resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform == 'emscripten'", + "sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "ast-serialize" +version = "0.8.0" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, +] + +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505" }, +] + +[[package]] +name = "numpy" +version = "2.5.2" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" }, +] + +[[package]] +name = "pandas" +version = "3.0.5" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/bf/09/7b95c4a0025227d6f118c4039b423412ac6a982db02864166185d812fbc7/pandas-3.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/8d/0c/dc78fd8c4da477b4b5e8ad37295af352190d21ef63a9ee1bc071753074cc/pandas-3.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/3e/71/3592c055cf44df9808550f9368ceda80ff2b224d355ef73fe251dcda1802/pandas-3.0.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/e3/70/4363150359f95b4cb4bcbb34ca23572bb5495749a621a8f3d5a1ddfd293c/pandas-3.0.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/f7/d0/317e7a0c67c0e69fa905a0161409397a7dc2d46ff611f6ca4803352c042b/pandas-3.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/f1/8d/36dade89b49e4f9d5cbdbe863772581f98c0c6d78fc39ad4c557f6f2e17e/pandas-3.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/de/ec/28a57266b753799a87b8bc79e7887ac6fd981b8c6d2978a0b7e7b6bd708c/pandas-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" }, +] + +[[package]] +name = "quant-engine" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "loguru" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "scipy" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "loguru", specifier = ">=0.7" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, + { name = "numpy", specifier = ">=1.24" }, + { name = "pandas", specifier = ">=2.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, + { name = "scipy", specifier = ">=1.10" }, +] +provides-extras = ["dev"] + +[[package]] +name = "ruff" +version = "0.16.4" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21" }, +] + +[[package]] +name = "scipy" +version = "1.18.1" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/9a/d7/21d890274f75ea37a8209d5519e72da3da90302e3b9fb8397a0918386a62/scipy-1.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/ec/01/798430ecea2e78ec7c02663d5f71c007bb6abeca931080debd40d7fa55ea/scipy-1.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/e6/5f/4634e9d35c68496e4e34cb6946eafab044458e6cedab42b40b6588e475b6/scipy-1.18.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/00/bd/bf5a4be6a3525676499f6dff307991739ff6fdcad1481b1aeb6745339f58/scipy-1.18.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/93/0e/e0348fbc0dbab65c114cf78957e7dfeb49f8e8b556b4d930cc12ff195e18/scipy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28" }, + { url = "https://mirrors.cloud.tencent.com/pypi/packages/50/a8/6a77f5f267c555108f0a864b6db714363dab567a8266422a79a385f9232b/scipy-1.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://mirrors.cloud.tencent.com/pypi/simple" } +sdist = { url = "https://mirrors.cloud.tencent.com/pypi/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0" } +wheels = [ + { url = "https://mirrors.cloud.tencent.com/pypi/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390" }, +]