Skip to content

Commit acc33dd

Browse files
authored
feat(data): add decision-data binding contract
Validated contract-only migration foundation.
2 parents 701cc4d + 7ec5d78 commit acc33dd

5 files changed

Lines changed: 367 additions & 0 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# 决策数据绑定(v1)
2+
3+
`DecisionDataBinding` 用来说明某次策略决策依赖的历史市场数据证据,和券商账户、实时执行报价、订单执行是三条不同的通道。
4+
5+
```text
6+
P1 数据采集 / 多源校验 → 冻结数据工件 → 策略决策
7+
券商账户 / 持仓 ----------------------------→ 执行风控
8+
执行端实时价格 ----------------------------→ 数量换算与价格保护
9+
订单意图 ----------------------------------→ 券商执行
10+
```
11+
12+
绑定中仅允许保存稳定 ID、日期、复权口径、来源 ID 和 SHA-256 摘要。不得保存:API key、账户号、供应商 URL、签名链接、原始行情或供应商错误正文。
13+
14+
迁移阶段有三种模式:
15+
16+
- `legacy_runtime_fetch`:旧运行时取数路径,必须显式标为 `LEGACY`,用于观察而非默认为已验证。
17+
- `artifact_optional`:可同时比较冻结工件与旧路径,工件不能替代执行端报价。
18+
- `artifact_required`:策略历史输入必须匹配冻结工件和绑定哈希;缺失或非 `VERIFIED` 时,运行时应禁止新增风险。
19+
20+
`DecisionDataArtifactPort` 仅加载已验证的历史决策数据;`ExecutionQuotePort` 仅提供短时执行报价。原有 `MarketDataPort` 在迁移期间保留兼容,新的策略代码不应再把它同时用于历史决策和实时下单保护。

src/quant_platform_kit/common/ports.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,39 @@
77

88

99
class MarketDataPort(Protocol):
10+
"""Legacy mixed market-data interface kept for adapter compatibility."""
11+
1012
def get_price_series(self, symbol: str, *, start: datetime | None = None, end: datetime | None = None) -> PriceSeries:
1113
"""Return historical close series for one symbol."""
1214

1315
def get_quote(self, symbol: str) -> QuoteSnapshot:
1416
"""Return the latest quote snapshot for one symbol."""
1517

1618

19+
class DecisionDataArtifactPort(Protocol):
20+
"""Load a verified, immutable strategy decision-data artifact.
21+
22+
The binding identifier and digest are public-safe evidence. Providers,
23+
paths, credentials, and transport belong to the implementing adapter.
24+
"""
25+
26+
def load_verified_price_series(
27+
self,
28+
symbol: str,
29+
*,
30+
binding_id: str,
31+
binding_sha256: str,
32+
) -> PriceSeries:
33+
"""Return a price series only when it matches the expected binding."""
34+
35+
36+
class ExecutionQuotePort(Protocol):
37+
"""Return a short-lived quote used only for execution safeguards."""
38+
39+
def get_execution_quote(self, symbol: str) -> QuoteSnapshot:
40+
"""Return the latest broker/execution quote for one symbol."""
41+
42+
1743
class PortfolioPort(Protocol):
1844
def get_portfolio_snapshot(self) -> PortfolioSnapshot:
1945
"""Return current account equity, buying power, cash, and positions."""

src/quant_platform_kit/data/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,28 @@
3131
MultiSourceDailyBarPolicy,
3232
assess_multisource_daily_bars,
3333
)
34+
from quant_platform_kit.data.decision_data_binding import (
35+
DECISION_DATA_ASSURANCE_DEGRADED,
36+
DECISION_DATA_ASSURANCE_LEGACY,
37+
DECISION_DATA_ASSURANCE_PARKED,
38+
DECISION_DATA_ASSURANCE_VERIFIED,
39+
DECISION_DATA_BINDING_SCHEMA_VERSION,
40+
DECISION_DATA_MODE_ARTIFACT_OPTIONAL,
41+
DECISION_DATA_MODE_ARTIFACT_REQUIRED,
42+
DECISION_DATA_MODE_LEGACY_RUNTIME_FETCH,
43+
DecisionDataBinding,
44+
)
3445

3546
__all__ = [
3647
"DataVersion",
48+
"DECISION_DATA_ASSURANCE_DEGRADED",
49+
"DECISION_DATA_ASSURANCE_LEGACY",
50+
"DECISION_DATA_ASSURANCE_PARKED",
51+
"DECISION_DATA_ASSURANCE_VERIFIED",
52+
"DECISION_DATA_BINDING_SCHEMA_VERSION",
53+
"DECISION_DATA_MODE_ARTIFACT_OPTIONAL",
54+
"DECISION_DATA_MODE_ARTIFACT_REQUIRED",
55+
"DECISION_DATA_MODE_LEGACY_RUNTIME_FETCH",
3756
"DATA_ASSURANCE_STATUS_DEGRADED",
3857
"DATA_ASSURANCE_STATUS_PARKED",
3958
"DATA_ASSURANCE_STATUS_VERIFIED",
@@ -43,6 +62,7 @@
4362
"SOURCE_OBSERVATION_READY",
4463
"SOURCE_OBSERVATION_UNAVAILABLE",
4564
"DailyBar",
65+
"DecisionDataBinding",
4666
"DailyBarSourceObservation",
4767
"DailyBarSourceSnapshot",
4868
"MultiSourceDailyBarAssurance",
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""Safe, immutable bindings for strategy decision-data artifacts.
2+
3+
Decision data is deliberately distinct from execution-time quotes. A binding
4+
contains only stable identifiers and evidence hashes, never provider URLs,
5+
credentials, account identifiers, or market-data payloads. This lets a
6+
runtime prove which frozen input it used without exposing private operational
7+
details through a control plane or an execution report.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from dataclasses import dataclass
13+
from datetime import date
14+
from hashlib import sha256
15+
import json
16+
import re
17+
from typing import Any, Mapping
18+
19+
20+
DECISION_DATA_BINDING_SCHEMA_VERSION = "qpk.decision_data_binding.v1"
21+
22+
DECISION_DATA_MODE_LEGACY_RUNTIME_FETCH = "legacy_runtime_fetch"
23+
DECISION_DATA_MODE_ARTIFACT_OPTIONAL = "artifact_optional"
24+
DECISION_DATA_MODE_ARTIFACT_REQUIRED = "artifact_required"
25+
26+
DECISION_DATA_ASSURANCE_LEGACY = "LEGACY"
27+
DECISION_DATA_ASSURANCE_VERIFIED = "VERIFIED"
28+
DECISION_DATA_ASSURANCE_DEGRADED = "DEGRADED"
29+
DECISION_DATA_ASSURANCE_PARKED = "PARKED"
30+
31+
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
32+
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
33+
_MODES = frozenset(
34+
{
35+
DECISION_DATA_MODE_LEGACY_RUNTIME_FETCH,
36+
DECISION_DATA_MODE_ARTIFACT_OPTIONAL,
37+
DECISION_DATA_MODE_ARTIFACT_REQUIRED,
38+
}
39+
)
40+
_ASSURANCE_STATUSES = frozenset(
41+
{
42+
DECISION_DATA_ASSURANCE_LEGACY,
43+
DECISION_DATA_ASSURANCE_VERIFIED,
44+
DECISION_DATA_ASSURANCE_DEGRADED,
45+
DECISION_DATA_ASSURANCE_PARKED,
46+
}
47+
)
48+
49+
50+
def _canonical_bytes(value: object) -> bytes:
51+
return json.dumps(
52+
value,
53+
allow_nan=False,
54+
ensure_ascii=True,
55+
separators=(",", ":"),
56+
sort_keys=True,
57+
).encode("ascii")
58+
59+
60+
def _require_identifier(value: object, *, field_name: str) -> str:
61+
text = str(value or "").strip()
62+
if not _IDENTIFIER_RE.fullmatch(text):
63+
raise ValueError(f"{field_name} must be a stable identifier")
64+
return text
65+
66+
67+
def _require_date(value: object, *, field_name: str) -> str:
68+
text = str(value or "").strip()
69+
try:
70+
return date.fromisoformat(text).isoformat()
71+
except ValueError as exc:
72+
raise ValueError(f"{field_name} must be an ISO-8601 date") from exc
73+
74+
75+
def _require_sha256(value: object, *, field_name: str) -> str:
76+
text = str(value or "").strip().lower().removeprefix("sha256:")
77+
if not _SHA256_RE.fullmatch(text):
78+
raise ValueError(f"{field_name} must be a SHA-256 digest")
79+
return text
80+
81+
82+
@dataclass(frozen=True)
83+
class DecisionDataBinding:
84+
"""A redacted, versioned reference to one strategy decision-data input.
85+
86+
``legacy_runtime_fetch`` exists only for an explicit, observable migration
87+
period. Artifact modes require a content hash, cutoff date, adjustment
88+
basis, and source identities. A caller resolves the actual private
89+
artifact location through its own environment, not through this contract.
90+
"""
91+
92+
binding_id: str
93+
strategy_scope: str
94+
mode: str
95+
source_ids: tuple[str, ...] = ()
96+
as_of: str | None = None
97+
adjustment_basis: str | None = None
98+
artifact_sha256: str | None = None
99+
assurance_status: str = DECISION_DATA_ASSURANCE_LEGACY
100+
schema_version: str = DECISION_DATA_BINDING_SCHEMA_VERSION
101+
102+
def __post_init__(self) -> None:
103+
object.__setattr__(self, "binding_id", _require_identifier(self.binding_id, field_name="binding_id"))
104+
object.__setattr__(self, "strategy_scope", _require_identifier(self.strategy_scope, field_name="strategy_scope"))
105+
106+
mode = str(self.mode or "").strip()
107+
if mode not in _MODES:
108+
raise ValueError("mode is unsupported")
109+
object.__setattr__(self, "mode", mode)
110+
111+
schema_version = str(self.schema_version or "").strip()
112+
if schema_version != DECISION_DATA_BINDING_SCHEMA_VERSION:
113+
raise ValueError("schema_version is unsupported")
114+
object.__setattr__(self, "schema_version", schema_version)
115+
116+
source_ids = tuple(_require_identifier(value, field_name="source_ids[]") for value in self.source_ids)
117+
if len(set(source_ids)) != len(source_ids):
118+
raise ValueError("source_ids must not contain duplicates")
119+
object.__setattr__(self, "source_ids", source_ids)
120+
121+
assurance_status = str(self.assurance_status or "").strip().upper()
122+
if assurance_status not in _ASSURANCE_STATUSES:
123+
raise ValueError("assurance_status is unsupported")
124+
object.__setattr__(self, "assurance_status", assurance_status)
125+
126+
if mode == DECISION_DATA_MODE_LEGACY_RUNTIME_FETCH:
127+
if self.artifact_sha256 is not None or self.as_of is not None or self.adjustment_basis is not None:
128+
raise ValueError("legacy_runtime_fetch must not claim an immutable artifact")
129+
if assurance_status != DECISION_DATA_ASSURANCE_LEGACY:
130+
raise ValueError("legacy_runtime_fetch must use LEGACY assurance_status")
131+
return
132+
133+
if not source_ids:
134+
raise ValueError("artifact decision-data modes require source_ids")
135+
object.__setattr__(self, "as_of", _require_date(self.as_of, field_name="as_of"))
136+
object.__setattr__(
137+
self,
138+
"adjustment_basis",
139+
_require_identifier(self.adjustment_basis, field_name="adjustment_basis"),
140+
)
141+
object.__setattr__(
142+
self,
143+
"artifact_sha256",
144+
_require_sha256(self.artifact_sha256, field_name="artifact_sha256"),
145+
)
146+
if assurance_status == DECISION_DATA_ASSURANCE_LEGACY:
147+
raise ValueError("artifact decision-data modes must declare an assurance status")
148+
149+
def to_dict(self) -> dict[str, object]:
150+
"""Return the public-safe contract payload without an artifact location."""
151+
152+
payload: dict[str, object] = {
153+
"schema_version": self.schema_version,
154+
"binding_id": self.binding_id,
155+
"strategy_scope": self.strategy_scope,
156+
"mode": self.mode,
157+
"source_ids": list(self.source_ids),
158+
"assurance_status": self.assurance_status,
159+
}
160+
if self.mode != DECISION_DATA_MODE_LEGACY_RUNTIME_FETCH:
161+
payload.update(
162+
{
163+
"as_of": self.as_of,
164+
"adjustment_basis": self.adjustment_basis,
165+
"artifact_sha256": self.artifact_sha256,
166+
}
167+
)
168+
return payload
169+
170+
@property
171+
def binding_sha256(self) -> str:
172+
return sha256(_canonical_bytes(self.to_dict())).hexdigest()
173+
174+
@classmethod
175+
def from_dict(cls, payload: Mapping[str, Any]) -> "DecisionDataBinding":
176+
"""Parse a public-safe binding payload and reject unknown fields."""
177+
178+
if not isinstance(payload, Mapping):
179+
raise ValueError("decision data binding must be an object")
180+
expected = {
181+
"schema_version",
182+
"binding_id",
183+
"strategy_scope",
184+
"mode",
185+
"source_ids",
186+
"as_of",
187+
"adjustment_basis",
188+
"artifact_sha256",
189+
"assurance_status",
190+
}
191+
unsupported = sorted(set(payload) - expected)
192+
if unsupported:
193+
raise ValueError("decision data binding contains unsupported fields: " + ", ".join(unsupported))
194+
return cls(
195+
schema_version=payload.get("schema_version", DECISION_DATA_BINDING_SCHEMA_VERSION),
196+
binding_id=payload.get("binding_id"),
197+
strategy_scope=payload.get("strategy_scope"),
198+
mode=payload.get("mode"),
199+
source_ids=tuple(payload.get("source_ids") or ()),
200+
as_of=payload.get("as_of"),
201+
adjustment_basis=payload.get("adjustment_basis"),
202+
artifact_sha256=payload.get("artifact_sha256"),
203+
assurance_status=payload.get("assurance_status", DECISION_DATA_ASSURANCE_LEGACY),
204+
)

0 commit comments

Comments
 (0)