From 9ce0c511e6ec873c1b4de3ca0c55e063a8fc0290 Mon Sep 17 00:00:00 2001 From: PureSaber Date: Wed, 14 Oct 2026 22:44:35 +0800 Subject: [PATCH 1/2] feat: validate shared allocation capabilities and frozen return forecast inputs --- pyproject.toml | 5 +- requirements.lock | 4 +- src/quant_lab/research.py | 6 ++- src/quant_lab/research_options.py | 79 +++++++++++++------------------ tests/test_research_options.py | 7 ++- 5 files changed, 46 insertions(+), 55 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 37f8d78..391652c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ readme = "README.md" license = { text = "MIT" } requires-python = ">=3.10" dependencies = [ + "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@ad3440700b226497c40093197f1df4321b49d1e8", "numpy>=1.24", "pandas>=2.0", "pyarrow>=14.0", @@ -19,8 +20,8 @@ dependencies = [ [project.optional-dependencies] statistics = ["arch>=7.2,<9"] rolling-research = [ - "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@fd080755eebdd5851d98e2b00830235f39ea5ce7", - "quant-factors @ git+https://github.com/PureSaber/quant-factors.git@0d7eb5c55ad47ca25271ad7a29cae19f482da5b7", + "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@ad3440700b226497c40093197f1df4321b49d1e8", + "quant-factors @ git+https://github.com/PureSaber/quant-factors.git@1c798bc737bc92fa5eca85dd0663dc608104f721", ] dev = ["pytest>=7.4", "pytest-cov>=5.0", "ruff>=0.9.0", "arch>=7.2,<9", "setuptools>=68", "wheel>=0.46"] diff --git a/requirements.lock b/requirements.lock index 98d95a6..2c30050 100644 --- a/requirements.lock +++ b/requirements.lock @@ -76,11 +76,11 @@ pyyaml==6.0.3 # quant-lab (pyproject.toml) # quant-data-kit # quant-factors -quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@fd080755eebdd5851d98e2b00830235f39ea5ce7 +quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@ad3440700b226497c40093197f1df4321b49d1e8 # via # quant-lab (pyproject.toml) # quant-factors -quant-factors @ git+https://github.com/PureSaber/quant-factors.git@0d7eb5c55ad47ca25271ad7a29cae19f482da5b7 +quant-factors @ git+https://github.com/PureSaber/quant-factors.git@1c798bc737bc92fa5eca85dd0663dc608104f721 # via quant-lab (pyproject.toml) referencing==0.37.0 # via diff --git a/src/quant_lab/research.py b/src/quant_lab/research.py index fed4464..0501128 100644 --- a/src/quant_lab/research.py +++ b/src/quant_lab/research.py @@ -147,7 +147,9 @@ def validate_recipe(value: dict) -> dict: else: _number(number, field, lower=-1 if field == "minimum_funding_rate" else 0) inputs = _closed( - recipe.get("inputs"), {"bundle", "history", "catalog", "source", "config"}, "inputs" + recipe.get("inputs"), + {"bundle", "history", "catalog", "source", "config", "return_forecasts"}, + "inputs", ) if not inputs or any(not isinstance(v, str) or not v.strip() for v in inputs.values()): raise ValueError("Explicit input references are required") @@ -303,7 +305,7 @@ def validate_recipe(value: dict) -> dict: def load_recipe(path: Path) -> dict: recipe = validate_recipe(yaml.safe_load(path.read_text(encoding="utf-8"))) - for key in ("bundle", "history", "catalog", "config"): + for key in ("bundle", "history", "catalog", "config", "return_forecasts"): if key in recipe["inputs"]: recipe["inputs"][key] = str((path.parent / recipe["inputs"][key]).resolve()) return recipe diff --git a/src/quant_lab/research_options.py b/src/quant_lab/research_options.py index 0980d08..86af9e4 100644 --- a/src/quant_lab/research_options.py +++ b/src/quant_lab/research_options.py @@ -107,8 +107,19 @@ def validate_options(recipe: dict) -> None: raise ValueError("Neutralization requires explicit PIT history mappings") if "risk_model" in recipe: validate_risk_model(recipe["risk_model"], recipe.get("required_history", {})) - if recipe.get("allocation", {}).get("mode") not in {"equal", "cost_aware"}: - raise ValueError("risk_model requires equal or cost_aware allocation") + if recipe.get("allocation", {}).get("mode") not in { + "equal", + "cost_aware", + "inverse_vol", + "max_sharpe", + "max_diversification", + "cvar", + "evar", + "cdar", + }: + raise ValueError( + "risk_model does not support dense-only risk_parity or hrp target construction" + ) def validate_risk(recipe): @@ -127,8 +138,19 @@ def validate_risk(recipe): raise ValueError("Invalid industry weight limit") if recipe.get("required_history", {}).get(risk.get("industry_field")) != "classification": raise ValueError("Industry limits require a PIT classification field") - if recipe.get("allocation", {}).get("mode") not in {"equal", "cost_aware"}: - raise ValueError("Industry limits require equal or cost_aware allocation") + if recipe.get("allocation", {}).get("mode") not in { + "equal", + "cost_aware", + "inverse_vol", + "max_sharpe", + "max_diversification", + "cvar", + "evar", + "cdar", + }: + raise ValueError( + "Industry limits do not support dense-only risk_parity or hrp target construction" + ) elif "industry_field" in risk: raise ValueError("industry_field requires max_industry_weight") @@ -260,49 +282,12 @@ def validate_risk_model(value, histories): def validate_allocation(value): - fields = { - "mode", - "lookback", - "min_observations", - "covariance_shrinkage", - "risk_aversion", - "turnover_penalty", - "max_turnover", - } - if not isinstance(value, dict) or set(value) - fields: - raise ValueError("Unknown allocation options") - result = { - "lookback": 20, - "min_observations": 10, - "covariance_shrinkage": 0.2, - "risk_aversion": 5.0, - "turnover_penalty": 0.0, - "max_turnover": 1.0, - **value, - } - if result.get("mode") not in {"equal", "inverse_vol", "cost_aware"}: - raise ValueError("Unknown allocation mode") - if any(type(result[k]) is not int or result[k] < 2 for k in ("lookback", "min_observations")): - raise ValueError("Invalid allocation observations") - if result["min_observations"] > result["lookback"]: - raise ValueError("Allocation min_observations exceeds lookback") - for key in ("covariance_shrinkage", "risk_aversion", "turnover_penalty", "max_turnover"): - number = result[key] - if ( - isinstance(number, bool) - or not isinstance(number, (int, float)) - or not math.isfinite(number) - ): - raise ValueError("Allocation values must be finite numbers") - if number < 0: - raise ValueError("Allocation values cannot be negative") - if ( - result["covariance_shrinkage"] > 1 - or result["risk_aversion"] == 0 - or result["max_turnover"] > 2 - ): - raise ValueError("Allocation constraint out of range") - return result + from quant_data_kit.allocation_protocol import validate_research_allocation + + try: + return validate_research_allocation(value) + except TypeError as exc: + raise ValueError(str(exc)) from exc def validate_execution(value, required_history): diff --git a/tests/test_research_options.py b/tests/test_research_options.py index dfa84f6..db610fb 100644 --- a/tests/test_research_options.py +++ b/tests/test_research_options.py @@ -98,9 +98,12 @@ def test_factor_and_industry_risk_allow_only_joint_constraint_allocators(mode): validate_options(recipe) validate_risk(recipe) recipe["allocation"] = {"mode": "inverse_vol"} - with pytest.raises(ValueError, match="risk_model requires equal or cost_aware"): + validate_options(recipe) + validate_risk(recipe) + recipe["allocation"] = {"mode": "hrp"} + with pytest.raises(ValueError, match="risk_model does not support"): validate_options(recipe) - with pytest.raises(ValueError, match="Industry limits require equal or cost_aware"): + with pytest.raises(ValueError, match="Industry limits do not support"): validate_risk(recipe) From 4c48f8a678c8d29a7dc46294c99f0e668cd6c52b Mon Sep 17 00:00:00 2001 From: PureSaber Date: Wed, 14 Oct 2026 22:48:30 +0800 Subject: [PATCH 2/2] docs: explain optimizer horizon and frozen return forecast migration --- docs/research-recipes.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/research-recipes.md b/docs/research-recipes.md index 8941bdb..39842e4 100644 --- a/docs/research-recipes.md +++ b/docs/research-recipes.md @@ -11,3 +11,9 @@ 默认exploratory。初始化prospective必须提供未来holdout起止日;运行时TrialRegistry再次核验。登记未来区间不等于已经取得留出成绩,留出评价沿用独立seal_holdout接口。历史分段、ICIR和参数扰动不是未触碰的样本外证据。 完整模板、固定依赖环境和运行入口位于quant-workspace的`profiles/research-workbench`。期货/加密模板只接受各自明确的backend_parameters;其费用和会计规则来自隔离的冻结fixture运行环境。 + +优化器新模板为 `templates/etf-optimizer-v2.yaml`,默认使用成熟、非重叠标签估计的滚动 `ic_vol`,明确预测交易日数。旧模板与冻结账户不会自动迁移。`risk_objective: active` 目前仅供 `cost_aware`,必须提供完整基准;部分现金不会被资金切片归一化隐藏。Barra 估计的 `risk_horizon` 应与预测期限一致。 + +显式预测使用 `expected_return_model: provided` 和 `inputs.return_forecasts` 指向冻结 CSV。列必须为 `symbol,forecast_at,available_at,horizon_sessions,expected_return,model_version`,时间带时区,收益为对应期限的小数收益。只使用调仓当天、当时已可用的最新修订;缺少候选资产或期限不符会停止候选。输入哈希参与研究身份,原始文件保存在结果中。原始分数只在未声明 v2 的兼容配置中保留。 + +结果同时记录连续权重、整手目标、实际持仓风险、约束残差、求解器及风险快照版本。无法找到通过账户和风险复核的整手方案时记录 `repair_failed`;求解失败不会自动改成等权。工程比较应固定输入和评价区间,保留所有失败候选,不按最终历史夏普回选参数。