Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/research-recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`;求解失败不会自动改成等权。工程比较应固定输入和评价区间,保留所有失败候选,不按最终历史夏普回选参数。
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"]

Expand Down
4 changes: 2 additions & 2 deletions requirements.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/quant_lab/research.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
79 changes: 32 additions & 47 deletions src/quant_lab/research_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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")

Expand Down Expand Up @@ -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):
Expand Down
7 changes: 5 additions & 2 deletions tests/test_research_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
Loading