Skip to content

Commit ac20c8d

Browse files
Pigbibicodex
andcommitted
feat: verify portable decision price-series artifacts
Co-Authored-By: Codex <noreply@openai.com>
1 parent 8c081eb commit ac20c8d

4 files changed

Lines changed: 516 additions & 0 deletions

File tree

docs/decision-data-binding.zh-CN.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,16 @@ P1 数据采集 / 多源校验 → 冻结数据工件 → 策略决策
1818
- `artifact_required`:策略历史输入必须匹配冻结工件和绑定哈希;缺失或非 `VERIFIED` 时,运行时应禁止新增风险。
1919

2020
`DecisionDataArtifactPort` 仅加载已验证的历史决策数据;`ExecutionQuotePort` 仅提供短时执行报价。原有 `MarketDataPort` 在迁移期间保留兼容,新的策略代码不应再把它同时用于历史决策和实时下单保护。
21+
22+
## 可移植日线投影(v1)
23+
24+
P1 的原始根目录可以随策略而不同:例如 TQQQ 是 OHLCV,SOXL 的部分候选只需要复权收盘价。运行平台不能猜测这些私有格式。因此,准备让运行时消费的 P1 根目录必须额外包含:
25+
26+
- `decision-price-series.json``qpk.decision_price_series_artifact.v1`;按标的提供日线 `as_of``close` 和可空的 `volume`
27+
- `manifest.json`:继续使用 `research_input_manifest.v1`,并把上面文件的大小和 SHA-256 作为成员写入。
28+
29+
运行端先用公开绑定中的 `artifact_sha256` 校验 **规范化** `manifest.json`,再校验 `decision-price-series.json` 的成员哈希,最后比对 `strategy_scope`、最后交易日、复权口径和来源 ID。任何一步不匹配都不能返回历史序列。
30+
31+
这里没有把 `binding_sha256` 写进投影,以避免“绑定包含清单摘要、清单又包含投影”的循环哈希。运行时会在读回投影后重新计算并验证绑定身份。
32+
33+
存储地址只能由部署环境的私有解析器提供,不能出现在 `RuntimeTarget`、控制台页面或执行回报中。P1 仍须先完成自身多源校验;这个投影只是经过 P1 验证后给平台使用的通用出口,不替代 P1、P3 或 P4--P6 的晋升门槛。

src/quant_platform_kit/data/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@
4242
DECISION_DATA_MODE_LEGACY_RUNTIME_FETCH,
4343
DecisionDataBinding,
4444
)
45+
from quant_platform_kit.data.decision_price_series_artifact import (
46+
DECISION_PRICE_SERIES_ARTIFACT_SCHEMA_VERSION,
47+
DECISION_PRICE_SERIES_MEMBER_PATH,
48+
InvalidDecisionDataArtifact,
49+
canonical_decision_price_series_artifact_bytes,
50+
price_series_from_decision_price_series_artifact,
51+
read_decision_price_series_artifact_json,
52+
validate_decision_price_series_artifact,
53+
verify_decision_price_series_artifact_members,
54+
)
4555

4656
__all__ = [
4757
"DataVersion",
@@ -63,15 +73,23 @@
6373
"SOURCE_OBSERVATION_UNAVAILABLE",
6474
"DailyBar",
6575
"DecisionDataBinding",
76+
"DECISION_PRICE_SERIES_ARTIFACT_SCHEMA_VERSION",
77+
"DECISION_PRICE_SERIES_MEMBER_PATH",
6678
"DailyBarSourceObservation",
6779
"DailyBarSourceSnapshot",
6880
"MultiSourceDailyBarAssurance",
6981
"MultiSourceDailyBarPolicy",
82+
"InvalidDecisionDataArtifact",
7083
"assess_multisource_daily_bars",
84+
"canonical_decision_price_series_artifact_bytes",
7185
"build_artifact_record",
7286
"latest_version",
87+
"price_series_from_decision_price_series_artifact",
88+
"read_decision_price_series_artifact_json",
7389
"resolve_version",
7490
"semver_version",
91+
"validate_decision_price_series_artifact",
92+
"verify_decision_price_series_artifact_members",
7593
"write_artifact_manifest",
7694
"write_data_release",
7795
]
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
"""Portable, verified daily decision-data projections.
2+
3+
This contract intentionally carries historical decision inputs only. It does
4+
not describe a storage location, a broker account, or a short-lived execution
5+
quote. Pipeline-specific P1 validators remain responsible for proving their
6+
native input root; this module verifies the small portable projection that a
7+
runtime may consume after that proof has been published immutably.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from collections.abc import Mapping
13+
from datetime import UTC, date, datetime, time
14+
from hashlib import sha256
15+
import json
16+
from math import isfinite
17+
import re
18+
from typing import Any
19+
20+
from quant_platform_kit.common.models import PricePoint, PriceSeries
21+
22+
from .decision_data_binding import (
23+
DECISION_DATA_ASSURANCE_VERIFIED,
24+
DECISION_DATA_MODE_ARTIFACT_OPTIONAL,
25+
DECISION_DATA_MODE_ARTIFACT_REQUIRED,
26+
DecisionDataBinding,
27+
)
28+
from .research_input import (
29+
canonical_research_input_manifest_bytes,
30+
read_research_input_manifest_json,
31+
research_input_manifest_sha256,
32+
)
33+
34+
35+
DECISION_PRICE_SERIES_ARTIFACT_SCHEMA_VERSION = "qpk.decision_price_series_artifact.v1"
36+
DECISION_PRICE_SERIES_MEMBER_PATH = "decision-price-series.json"
37+
38+
_SYMBOL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
39+
_CURRENCY_RE = re.compile(r"^[A-Z]{2,8}$")
40+
41+
42+
class InvalidDecisionDataArtifact(ValueError):
43+
"""Raised when a portable decision-data projection fails closed."""
44+
45+
46+
def _invalid() -> None:
47+
raise InvalidDecisionDataArtifact("invalid decision price-series artifact")
48+
49+
50+
def _canonical(value: object) -> bytes:
51+
return json.dumps(
52+
value,
53+
allow_nan=False,
54+
ensure_ascii=False,
55+
separators=(",", ":"),
56+
sort_keys=True,
57+
).encode("utf-8")
58+
59+
60+
def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
61+
result: dict[str, Any] = {}
62+
for key, value in pairs:
63+
if key in result:
64+
_invalid()
65+
result[key] = value
66+
return result
67+
68+
69+
def _reject_nonfinite_constant(_: str) -> None:
70+
_invalid()
71+
72+
73+
def _require_exact_mapping(value: object, keys: frozenset[str]) -> dict[str, object]:
74+
if not isinstance(value, Mapping) or set(value) != keys or any(
75+
not isinstance(key, str) for key in value
76+
):
77+
_invalid()
78+
return dict(value)
79+
80+
81+
def _require_date(value: object) -> str:
82+
if not isinstance(value, str):
83+
_invalid()
84+
try:
85+
parsed = date.fromisoformat(value)
86+
except ValueError:
87+
_invalid()
88+
if parsed.isoformat() != value:
89+
_invalid()
90+
return value
91+
92+
93+
def _require_identifier(value: object) -> str:
94+
if not isinstance(value, str):
95+
_invalid()
96+
text = value.strip()
97+
if not text or text != value:
98+
_invalid()
99+
return text
100+
101+
102+
def _require_number(value: object, *, positive: bool) -> float:
103+
if isinstance(value, bool) or not isinstance(value, (int, float)):
104+
_invalid()
105+
number = float(value)
106+
if not isfinite(number) or (number <= 0 if positive else number < 0):
107+
_invalid()
108+
return number
109+
110+
111+
def _require_source_ids(value: object) -> tuple[str, ...]:
112+
if not isinstance(value, list) or not value:
113+
_invalid()
114+
source_ids = tuple(_require_identifier(item) for item in value)
115+
if len(source_ids) != len(set(source_ids)):
116+
_invalid()
117+
return source_ids
118+
119+
120+
def validate_decision_price_series_artifact(value: object) -> dict[str, object]:
121+
"""Validate one provider-neutral, daily historical price projection."""
122+
123+
try:
124+
artifact = _require_exact_mapping(
125+
value,
126+
frozenset(
127+
{
128+
"schema_version",
129+
"strategy_scope",
130+
"as_of",
131+
"adjustment_basis",
132+
"source_ids",
133+
"series",
134+
}
135+
),
136+
)
137+
if artifact["schema_version"] != DECISION_PRICE_SERIES_ARTIFACT_SCHEMA_VERSION:
138+
_invalid()
139+
strategy_scope = _require_identifier(artifact["strategy_scope"])
140+
as_of = _require_date(artifact["as_of"])
141+
adjustment_basis = _require_identifier(artifact["adjustment_basis"])
142+
source_ids = _require_source_ids(artifact["source_ids"])
143+
raw_series = artifact["series"]
144+
if not isinstance(raw_series, Mapping) or not raw_series:
145+
_invalid()
146+
147+
normalized_series: dict[str, dict[str, object]] = {}
148+
for raw_symbol, raw_payload in raw_series.items():
149+
if not isinstance(raw_symbol, str) or not _SYMBOL_RE.fullmatch(raw_symbol):
150+
_invalid()
151+
symbol = raw_symbol.upper()
152+
if symbol != raw_symbol or symbol in normalized_series:
153+
_invalid()
154+
payload = _require_exact_mapping(raw_payload, frozenset({"currency", "points"}))
155+
currency = _require_identifier(payload["currency"])
156+
if not _CURRENCY_RE.fullmatch(currency):
157+
_invalid()
158+
raw_points = payload["points"]
159+
if not isinstance(raw_points, list) or not raw_points:
160+
_invalid()
161+
162+
points: list[dict[str, object]] = []
163+
prior_session: str | None = None
164+
for raw_point in raw_points:
165+
point = _require_exact_mapping(raw_point, frozenset({"as_of", "close", "volume"}))
166+
session = _require_date(point["as_of"])
167+
if prior_session is not None and session <= prior_session:
168+
_invalid()
169+
prior_session = session
170+
if session > as_of:
171+
_invalid()
172+
volume_raw = point["volume"]
173+
volume = None if volume_raw is None else _require_number(volume_raw, positive=False)
174+
points.append(
175+
{
176+
"as_of": session,
177+
"close": _require_number(point["close"], positive=True),
178+
"volume": volume,
179+
}
180+
)
181+
if prior_session != as_of:
182+
_invalid()
183+
normalized_series[symbol] = {"currency": currency, "points": points}
184+
185+
return {
186+
"schema_version": DECISION_PRICE_SERIES_ARTIFACT_SCHEMA_VERSION,
187+
"strategy_scope": strategy_scope,
188+
"as_of": as_of,
189+
"adjustment_basis": adjustment_basis,
190+
"source_ids": list(source_ids),
191+
"series": normalized_series,
192+
}
193+
except (InvalidDecisionDataArtifact, TypeError, ValueError, KeyError):
194+
raise InvalidDecisionDataArtifact("invalid decision price-series artifact") from None
195+
196+
197+
def canonical_decision_price_series_artifact_bytes(value: object) -> bytes:
198+
"""Return canonical bytes after strict validation."""
199+
200+
return _canonical(validate_decision_price_series_artifact(value))
201+
202+
203+
def read_decision_price_series_artifact_json(payload: bytes | str) -> dict[str, object]:
204+
"""Strictly parse a portable projection without accepting duplicate keys."""
205+
206+
try:
207+
if isinstance(payload, bytes):
208+
payload = payload.decode("utf-8")
209+
if not isinstance(payload, str):
210+
_invalid()
211+
parsed = json.loads(
212+
payload,
213+
object_pairs_hook=_reject_duplicate_keys,
214+
parse_constant=_reject_nonfinite_constant,
215+
)
216+
return validate_decision_price_series_artifact(parsed)
217+
except (InvalidDecisionDataArtifact, UnicodeDecodeError, TypeError, ValueError, json.JSONDecodeError):
218+
raise InvalidDecisionDataArtifact("invalid decision price-series artifact") from None
219+
220+
221+
def _require_verified_artifact_binding(binding: DecisionDataBinding) -> None:
222+
if (
223+
binding.mode
224+
not in {
225+
DECISION_DATA_MODE_ARTIFACT_OPTIONAL,
226+
DECISION_DATA_MODE_ARTIFACT_REQUIRED,
227+
}
228+
or binding.assurance_status != DECISION_DATA_ASSURANCE_VERIFIED
229+
):
230+
raise InvalidDecisionDataArtifact("decision data binding is not verified for artifact use")
231+
232+
233+
def price_series_from_decision_price_series_artifact(
234+
artifact: object,
235+
*,
236+
binding: DecisionDataBinding,
237+
) -> dict[str, PriceSeries]:
238+
"""Translate a verified projection only when its public identity matches."""
239+
240+
_require_verified_artifact_binding(binding)
241+
normalized = validate_decision_price_series_artifact(artifact)
242+
if (
243+
normalized["strategy_scope"] != binding.strategy_scope
244+
or normalized["as_of"] != binding.as_of
245+
or normalized["adjustment_basis"] != binding.adjustment_basis
246+
or tuple(normalized["source_ids"]) != binding.source_ids
247+
):
248+
raise InvalidDecisionDataArtifact("decision price-series artifact does not match binding")
249+
250+
result: dict[str, PriceSeries] = {}
251+
for symbol, raw_payload in normalized["series"].items():
252+
payload = _require_exact_mapping(raw_payload, frozenset({"currency", "points"}))
253+
points = tuple(
254+
PricePoint(
255+
as_of=datetime.combine(date.fromisoformat(point["as_of"]), time.min, tzinfo=UTC),
256+
close=float(point["close"]),
257+
volume=(None if point["volume"] is None else float(point["volume"])),
258+
)
259+
for point in payload["points"]
260+
)
261+
result[symbol] = PriceSeries(symbol=symbol, currency=str(payload["currency"]), points=points)
262+
return result
263+
264+
265+
def verify_decision_price_series_artifact_members(
266+
*,
267+
binding: DecisionDataBinding,
268+
manifest_bytes: bytes,
269+
decision_price_series_bytes: bytes,
270+
) -> dict[str, PriceSeries]:
271+
"""Verify immutable manifest/member bytes, then return safe price series.
272+
273+
A transport adapter resolves the private root. This function deliberately
274+
receives only bytes, so storage paths and credentials cannot enter the
275+
public runtime target or its execution report.
276+
"""
277+
278+
try:
279+
_require_verified_artifact_binding(binding)
280+
manifest = read_research_input_manifest_json(manifest_bytes)
281+
if manifest_bytes != canonical_research_input_manifest_bytes(manifest):
282+
_invalid()
283+
if research_input_manifest_sha256(manifest) != binding.artifact_sha256:
284+
_invalid()
285+
members = {str(member["path"]): member for member in manifest["members"]}
286+
member = members.get(DECISION_PRICE_SERIES_MEMBER_PATH)
287+
if not isinstance(member, Mapping):
288+
_invalid()
289+
if (
290+
member.get("size_bytes") != len(decision_price_series_bytes)
291+
or member.get("sha256") != sha256(decision_price_series_bytes).hexdigest()
292+
):
293+
_invalid()
294+
if decision_price_series_bytes != canonical_decision_price_series_artifact_bytes(
295+
read_decision_price_series_artifact_json(decision_price_series_bytes)
296+
):
297+
_invalid()
298+
return price_series_from_decision_price_series_artifact(
299+
read_decision_price_series_artifact_json(decision_price_series_bytes),
300+
binding=binding,
301+
)
302+
except (InvalidDecisionDataArtifact, TypeError, ValueError, KeyError):
303+
raise InvalidDecisionDataArtifact("invalid decision price-series artifact") from None
304+
305+
306+
__all__ = [
307+
"DECISION_PRICE_SERIES_ARTIFACT_SCHEMA_VERSION",
308+
"DECISION_PRICE_SERIES_MEMBER_PATH",
309+
"InvalidDecisionDataArtifact",
310+
"canonical_decision_price_series_artifact_bytes",
311+
"price_series_from_decision_price_series_artifact",
312+
"read_decision_price_series_artifact_json",
313+
"validate_decision_price_series_artifact",
314+
"verify_decision_price_series_artifact_members",
315+
]

0 commit comments

Comments
 (0)