Skip to content

Commit f9095b3

Browse files
Pigbibicodex
andcommitted
feat(risk): bind owner preference after private P3 ingress
Co-Authored-By: Codex <noreply@openai.com>
1 parent 4640d7f commit f9095b3

3 files changed

Lines changed: 213 additions & 8 deletions

File tree

docs/qsl_long_horizon_risk_composer_v1.zh-CN.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
# QSL 长期复利风险政策 Composer V1
22

3-
> 状态:`ADVISORY_CORE_IMPLEMENTED_NOT_WIRED`
3+
> 状态:`PRIVATE_P3_OBSERVATION_INGRESS_IMPLEMENTED_POLICY_WRITE_NOT_WIRED`
44
55
`python/scripts/long_horizon_risk_composer.py` 是所有策略、组合与插件可复用的离线风险设计内核。
66
它把已经冻结、净成本后的 P3 策略收益路径与同周期的无杠杆基准路径,计算成一个脱敏的风险尺度前沿。
77
它不读取账户、资金、券商、凭据或网络;不写入风险政策、不改策略参数、不启动调度,也不能授予 P4、P5 或 P6。
88

9+
研究管道与控制面之间使用 `qsl.long_horizon_risk_observation.v1`。研究管道只负责产出候选、P1/P2/P3/plugin 摘要、无杠杆基准和成对净收益路径;控制面必须显式叠加所有者选择的风险偏好,才可转换为 Composer 输入。观察件是**私有 ingress 工件**:不能上传到公开仓库、Actions 公开摘要、控制台或 AI 上下文;Composer 输出才是可发布的脱敏摘要。
10+
911
## 人和系统的分工
1012

1113
人工只选择三个简单、重要的偏好之一:
@@ -18,6 +20,8 @@
1820

1921
这三个倍数是透明、版本化的偏好模板,不是模型从历史数据“发现”的真理。系统计算的内容是每个候选在每个尺度下的实际净成本路径、最大回撤、相对基准回撤、水下持续期和每 session 对数几何增长;它不会把一次历史最优结果伪装成未来保证。
2022

23+
命令行也遵守这条分工:已有 owner-bound 输入可用 `--input`;私有观察件必须同时给出 `--observation``--risk-preference`。缺少偏好即失败,不会静默选择“均衡”或任何默认档位。
24+
2125
## 必要证据和计算方法
2226

2327
输入必须精确绑定 candidate revision 与 P1/P2/P3/plugin 摘要,并至少包含每类一个完整的 252-session 以上路径:
@@ -33,6 +37,8 @@
3337
- 最坏基准最大回撤与最长水下期;
3438
- 至少三分之二情景为正增长,且策略最坏回撤不超过所选偏好的基准回撤倍数时,才标记该尺度合格。
3539

40+
每个情景明确携带 `session_count`,且必须恰好比收益率数组多一个起点:例如 252 个 XNYS 观测日对应 251 个相邻日收益率。长度门检查的是已签名的观测日数量,避免把完整的一年前瞻窗口误判为不足,也避免把收益率数组伪装成更多交易日。
41+
3642
在所有合格尺度中,选择下中位数对数几何增长最高者;同分时选择更低风险尺度。输出的 `recommended_max_drawdown_bps` 是由冻结的基准路径和偏好模板计算出的候选上限,不是订单阈值或已启用政策。
3743

3844
## SOXL 与 TQQQ 的使用方式

python/scripts/long_horizon_risk_composer.py

Lines changed: 125 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
RISK_COMPOSER_INPUT_SCHEMA_ID = "qsl.long_horizon_risk_composer_input.v1"
3131
RISK_COMPOSER_RECOMMENDATION_SCHEMA_ID = "qsl.long_horizon_risk_composer_recommendation.v1"
32+
RISK_OBSERVATION_SCHEMA_ID = "qsl.long_horizon_risk_observation.v1"
3233
_IDENTITY_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
3334
_REPOSITORY_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$")
3435
_REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$")
@@ -41,10 +42,18 @@
4142
re.IGNORECASE,
4243
)
4344
_INPUT_FIELDS = {"schema", "candidate", "source_evidence", "objective", "scenario_paths", "input_sha256"}
45+
_OBSERVATION_FIELDS = {"schema", "candidate", "source_evidence", "benchmark", "scenario_paths", "observation_sha256"}
4446
_CANDIDATE_FIELDS = {"candidate_id", "candidate_kind", "strategy_repository", "strategy_revision"}
4547
_SOURCE_EVIDENCE_FIELDS = {"p1_input_digest", "p2_config_digest", "p3_evidence_sha256", "plugin_bundle_sha256"}
4648
_OBJECTIVE_FIELDS = {"risk_preference", "benchmark_id", "benchmark_kind", "sessions_per_year"}
47-
_SCENARIO_FIELDS = {"scenario_id", "scenario_kind", "strategy_returns_bps", "benchmark_returns_bps"}
49+
_BENCHMARK_FIELDS = {"benchmark_id", "benchmark_kind", "sessions_per_year"}
50+
_SCENARIO_FIELDS = {
51+
"scenario_id",
52+
"scenario_kind",
53+
"session_count",
54+
"strategy_returns_bps",
55+
"benchmark_returns_bps",
56+
}
4857
_RECOMMENDATION_FIELDS = {
4958
"schema",
5059
"candidate",
@@ -190,6 +199,13 @@ def calculate_risk_composer_recommendation_sha256(value: Mapping[str, Any]) -> s
190199
).hexdigest()
191200

192201

202+
def calculate_risk_observation_sha256(value: Mapping[str, Any]) -> str:
203+
"""Return the stable identity of one private P3 return-path observation."""
204+
return hashlib.sha256(
205+
_canonical_json(value, "observation_sha256", "long-horizon risk observation").encode("utf-8")
206+
).hexdigest()
207+
208+
193209
def _validate_candidate(value: Any) -> dict[str, str]:
194210
candidate = _expect_object(value, "candidate")
195211
_expect_exact_keys(candidate, _CANDIDATE_FIELDS, "candidate")
@@ -234,6 +250,20 @@ def _validate_objective(value: Any) -> dict[str, Any]:
234250
}
235251

236252

253+
def _validate_benchmark(value: Any) -> dict[str, Any]:
254+
benchmark = _expect_object(value, "benchmark")
255+
_expect_exact_keys(benchmark, _BENCHMARK_FIELDS, "benchmark")
256+
if benchmark["benchmark_kind"] != "unlevered_reference":
257+
_fail("benchmark.benchmark_kind must be unlevered_reference")
258+
return {
259+
"benchmark_id": _expect_identity(benchmark["benchmark_id"], "benchmark.benchmark_id"),
260+
"benchmark_kind": _expect_identity(benchmark["benchmark_kind"], "benchmark.benchmark_kind"),
261+
"sessions_per_year": _expect_positive_integer(
262+
benchmark["sessions_per_year"], "benchmark.sessions_per_year", maximum=366
263+
),
264+
}
265+
266+
237267
def _validate_scenario(value: Any, index: int) -> dict[str, Any]:
238268
path = f"scenario_paths[{index}]"
239269
scenario = _expect_object(value, path)
@@ -245,11 +275,17 @@ def _validate_scenario(value: Any, index: int) -> dict[str, Any]:
245275
benchmark_returns = _expect_list(scenario["benchmark_returns_bps"], f"{path}.benchmark_returns_bps")
246276
if len(strategy_returns) != len(benchmark_returns):
247277
_fail(f"{path} strategy and benchmark returns must have the same length")
248-
if len(strategy_returns) > _MAX_SESSIONS_PER_SCENARIO:
278+
session_count = _expect_positive_integer(
279+
scenario["session_count"], f"{path}.session_count", maximum=_MAX_SESSIONS_PER_SCENARIO
280+
)
281+
if len(strategy_returns) != session_count - 1:
282+
_fail(f"{path} must contain exactly one fewer return than its observed sessions")
283+
if len(strategy_returns) > _MAX_SESSIONS_PER_SCENARIO - 1:
249284
_fail(f"{path} exceeds the bounded session count")
250285
return {
251286
"scenario_id": _expect_identity(scenario["scenario_id"], f"{path}.scenario_id"),
252287
"scenario_kind": kind,
288+
"session_count": session_count,
253289
"strategy_returns_bps": [
254290
_expect_return_bps(item, f"{path}.strategy_returns_bps[{return_index}]")
255291
for return_index, item in enumerate(strategy_returns)
@@ -287,6 +323,68 @@ def validate_risk_composer_input(value: Any) -> dict[str, Any]:
287323
return normalized
288324

289325

326+
def validate_long_horizon_risk_observation(value: Any) -> dict[str, Any]:
327+
"""Validate a private P3 observation before an owner preference is bound.
328+
329+
The observation contains only frozen candidate identity, evidence digests,
330+
a same-window unlevered reference, and paired net-return paths. It is an
331+
internal ingress artifact: it is never suitable for a public console or
332+
AI prompt. Unlike a composer input it intentionally contains no risk
333+
preference, because that is a control-plane/owner decision.
334+
"""
335+
_reject_non_finite_or_null(value, "long-horizon risk observation")
336+
_reject_forbidden_material(value, "long-horizon risk observation")
337+
observation = _expect_object(value, "long-horizon risk observation")
338+
_expect_exact_keys(observation, _OBSERVATION_FIELDS, "long-horizon risk observation")
339+
if observation["schema"] != RISK_OBSERVATION_SCHEMA_ID:
340+
_fail(f"long-horizon risk observation.schema must be {RISK_OBSERVATION_SCHEMA_ID}")
341+
paths = _expect_list(observation["scenario_paths"], "observation.scenario_paths")
342+
if not paths or len(paths) > _MAX_SCENARIOS:
343+
_fail(f"observation.scenario_paths must contain between 1 and {_MAX_SCENARIOS} paths")
344+
normalized = {
345+
"schema": RISK_OBSERVATION_SCHEMA_ID,
346+
"candidate": _validate_candidate(observation["candidate"]),
347+
"source_evidence": _validate_source_evidence(observation["source_evidence"]),
348+
"benchmark": _validate_benchmark(observation["benchmark"]),
349+
"scenario_paths": [_validate_scenario(item, index) for index, item in enumerate(paths)],
350+
"observation_sha256": _expect_sha256(
351+
observation["observation_sha256"], "long-horizon risk observation.observation_sha256"
352+
),
353+
}
354+
if len({path["scenario_id"] for path in normalized["scenario_paths"]}) != len(normalized["scenario_paths"]):
355+
_fail("observation.scenario_paths.scenario_id values must be unique")
356+
if normalized["observation_sha256"] != calculate_risk_observation_sha256(normalized):
357+
_fail("long-horizon risk observation.observation_sha256 mismatch")
358+
return normalized
359+
360+
361+
def build_risk_composer_input_from_observation(
362+
observation: Any, *, risk_preference: str
363+
) -> dict[str, Any]:
364+
"""Attach one explicit owner preference to a frozen private observation.
365+
366+
This is deliberately a pure conversion. It does not refresh P3 data,
367+
choose a preference, write a policy, or authorize any lifecycle phase.
368+
"""
369+
normalized = validate_long_horizon_risk_observation(observation)
370+
objective = _validate_objective(
371+
{
372+
"risk_preference": risk_preference,
373+
**normalized["benchmark"],
374+
}
375+
)
376+
result: dict[str, Any] = {
377+
"schema": RISK_COMPOSER_INPUT_SCHEMA_ID,
378+
"candidate": normalized["candidate"],
379+
"source_evidence": normalized["source_evidence"],
380+
"objective": objective,
381+
"scenario_paths": normalized["scenario_paths"],
382+
"input_sha256": "",
383+
}
384+
result["input_sha256"] = calculate_risk_composer_input_sha256(result)
385+
return validate_risk_composer_input(result)
386+
387+
290388
def _scaled_return_bps(return_bps: int, scale_bps: int) -> int:
291389
product = return_bps * scale_bps
292390
return product // 10_000 if product >= 0 else -((-product + 9_999) // 10_000)
@@ -368,7 +466,7 @@ def compose_long_horizon_risk_recommendation(value: Any) -> dict[str, Any]:
368466
reasons: list[str] = []
369467
if kinds != _SCENARIO_KINDS:
370468
reasons.append("SCENARIO_KIND_COVERAGE_INCOMPLETE")
371-
if any(len(path["strategy_returns_bps"]) < _MIN_SESSIONS_PER_SCENARIO for path in paths):
469+
if any(path["session_count"] < _MIN_SESSIONS_PER_SCENARIO for path in paths):
372470
reasons.append("LONG_HORIZON_SESSION_COVERAGE_INCOMPLETE")
373471
if reasons:
374472
return _parked_recommendation(validated, reasons)
@@ -522,13 +620,33 @@ def parse_risk_composer_input_json(text: str) -> dict[str, Any]:
522620

523621
def main(argv: list[str] | None = None) -> int:
524622
parser = argparse.ArgumentParser(description="Compose a non-executing long-horizon risk recommendation")
525-
parser.add_argument("--input", type=Path, required=True, help="frozen P3 return-path evidence JSON")
623+
input_source = parser.add_mutually_exclusive_group(required=True)
624+
input_source.add_argument("--input", type=Path, help="private, owner-bound P3 return-path evidence JSON")
625+
input_source.add_argument(
626+
"--observation",
627+
type=Path,
628+
help="private P3 observation JSON; requires an explicit --risk-preference",
629+
)
630+
parser.add_argument(
631+
"--risk-preference",
632+
choices=tuple(sorted(_RISK_PREFERENCES)),
633+
help="owner-selected preference when converting a private observation",
634+
)
526635
parser.add_argument("--output", type=Path, required=True, help="advisory recommendation JSON")
527636
args = parser.parse_args(argv)
528637
try:
529-
recommendation = compose_long_horizon_risk_recommendation(
530-
parse_risk_composer_input_json(args.input.read_text(encoding="utf-8"))
531-
)
638+
if args.input is not None:
639+
if args.risk_preference is not None:
640+
_fail("--risk-preference is only valid with --observation")
641+
composer_input = parse_risk_composer_input_json(args.input.read_text(encoding="utf-8"))
642+
else:
643+
if args.risk_preference is None:
644+
_fail("--observation requires --risk-preference")
645+
composer_input = build_risk_composer_input_from_observation(
646+
parse_risk_composer_input_json(args.observation.read_text(encoding="utf-8")),
647+
risk_preference=args.risk_preference,
648+
)
649+
recommendation = compose_long_horizon_risk_recommendation(composer_input)
532650
validated = validate_risk_composer_recommendation(recommendation)
533651
args.output.write_text(
534652
json.dumps(validated, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n",

python/tests/test_long_horizon_risk_composer.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import importlib.util
55
import json
66
import sys
7+
import tempfile
78
import unittest
89
from pathlib import Path
910

@@ -44,6 +45,7 @@ def _input(self, *, preference: str = "BALANCED_COMPOUNDING") -> dict[str, objec
4445
{
4546
"scenario_id": f"soxl_soxx_longterm_{kind.lower()}_{index}",
4647
"scenario_kind": kind,
48+
"session_count": 253,
4749
"strategy_returns_bps": self._returns(16 - index, 124 + index),
4850
"benchmark_returns_bps": self._returns(10 - index, 100 + index),
4951
}
@@ -74,6 +76,23 @@ def _input(self, *, preference: str = "BALANCED_COMPOUNDING") -> dict[str, objec
7476
value["input_sha256"] = composer.calculate_risk_composer_input_sha256(value)
7577
return value
7678

79+
def _observation(self) -> dict[str, object]:
80+
composer_input = self._input()
81+
value: dict[str, object] = {
82+
"schema": "qsl.long_horizon_risk_observation.v1",
83+
"candidate": composer_input["candidate"],
84+
"source_evidence": composer_input["source_evidence"],
85+
"benchmark": {
86+
"benchmark_id": "soxx",
87+
"benchmark_kind": "unlevered_reference",
88+
"sessions_per_year": 252,
89+
},
90+
"scenario_paths": composer_input["scenario_paths"],
91+
"observation_sha256": "",
92+
}
93+
value["observation_sha256"] = composer.calculate_risk_observation_sha256(value)
94+
return value
95+
7796
def test_balanced_composer_selects_the_highest_robust_growth_scale_within_benchmark_drawdown_envelope(self):
7897
recommendation = composer.compose_long_horizon_risk_recommendation(self._input())
7998

@@ -120,8 +139,17 @@ def test_short_or_duplicate_scenarios_cannot_supply_a_long_horizon_recommendatio
120139
short = self._input()
121140
short["scenario_paths"][0]["strategy_returns_bps"] = [10] * 251
122141
short["scenario_paths"][0]["benchmark_returns_bps"] = [8] * 251
142+
short["scenario_paths"][0]["session_count"] = 252
123143
short["input_sha256"] = composer.calculate_risk_composer_input_sha256(short)
124144
recommendation = composer.compose_long_horizon_risk_recommendation(short)
145+
self.assertEqual(recommendation["status"], "ADVISORY_RECOMMENDATION_READY")
146+
147+
insufficient_sessions = self._input()
148+
insufficient_sessions["scenario_paths"][0]["strategy_returns_bps"] = [10] * 250
149+
insufficient_sessions["scenario_paths"][0]["benchmark_returns_bps"] = [8] * 250
150+
insufficient_sessions["scenario_paths"][0]["session_count"] = 251
151+
insufficient_sessions["input_sha256"] = composer.calculate_risk_composer_input_sha256(insufficient_sessions)
152+
recommendation = composer.compose_long_horizon_risk_recommendation(insufficient_sessions)
125153
self.assertEqual(recommendation["status"], "PARKED")
126154
self.assertIn("LONG_HORIZON_SESSION_COVERAGE_INCOMPLETE", recommendation["reason_codes"])
127155

@@ -144,6 +172,59 @@ def test_tampering_with_evidence_or_smuggling_capital_fails_closed(self):
144172
with self.assertRaisesRegex(composer.LongHorizonRiskComposerError, "capital_amount is forbidden"):
145173
composer.compose_long_horizon_risk_recommendation(unsafe)
146174

175+
def test_private_observation_needs_an_explicit_preference_before_composition(self):
176+
observation = self._observation()
177+
validated = composer.validate_long_horizon_risk_observation(observation)
178+
composer_input = composer.build_risk_composer_input_from_observation(
179+
validated,
180+
risk_preference="CAPITAL_PRESERVATION",
181+
)
182+
183+
self.assertEqual(composer_input["objective"]["risk_preference"], "CAPITAL_PRESERVATION")
184+
self.assertEqual(composer_input["objective"]["benchmark_id"], "soxx")
185+
self.assertEqual(composer_input, composer.validate_risk_composer_input(composer_input))
186+
self.assertEqual(
187+
composer.compose_long_horizon_risk_recommendation(composer_input)["status"],
188+
"ADVISORY_RECOMMENDATION_READY",
189+
)
190+
191+
tampered = copy.deepcopy(observation)
192+
tampered["benchmark"]["benchmark_id"] = "qqq"
193+
with self.assertRaisesRegex(composer.LongHorizonRiskComposerError, "observation_sha256 mismatch"):
194+
composer.build_risk_composer_input_from_observation(
195+
tampered,
196+
risk_preference="BALANCED_COMPOUNDING",
197+
)
198+
199+
def test_cli_accepts_private_observation_but_not_an_implicit_preference(self):
200+
with tempfile.TemporaryDirectory() as directory:
201+
root = Path(directory)
202+
observation = root / "observation.json"
203+
output = root / "recommendation.json"
204+
observation.write_text(json.dumps(self._observation()), encoding="utf-8")
205+
206+
self.assertEqual(
207+
composer.main(["--observation", str(observation), "--output", str(output)]),
208+
1,
209+
)
210+
self.assertFalse(output.exists())
211+
self.assertEqual(
212+
composer.main(
213+
[
214+
"--observation",
215+
str(observation),
216+
"--risk-preference",
217+
"GROWTH_COMPOUNDING",
218+
"--output",
219+
str(output),
220+
]
221+
),
222+
0,
223+
)
224+
result = json.loads(output.read_text(encoding="utf-8"))
225+
self.assertEqual(result["objective"]["risk_preference"], "GROWTH_COMPOUNDING")
226+
self.assertNotIn("strategy_returns", json.dumps(result))
227+
147228
def test_recommendation_digest_binds_the_frontier_and_prevents_policy_promotion_by_mutation(self):
148229
recommendation = composer.compose_long_horizon_risk_recommendation(self._input())
149230
tampered = copy.deepcopy(recommendation)

0 commit comments

Comments
 (0)