|
| 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