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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ P5 receipt 不是 P4/P6 权限。shadow input 会复核上游 policy-gate receip
此前的 v1 仅为未启用的纯账本原型,未签发任何真实 receipt;v2 因此不保留可绕过
policy-gate receipt 的兼容入口。

## P4 paper admission(本地准入契约)

`paper_admission` 只生成和校验 `qsl.alpaca_paper_admission.v1`,不连接 Alpaca、不读取凭据、
不访问网络、不排程、不提交订单。契约必须显式使用 `PAPER_DRY_RUN`,并只包含 paper endpoint、
配置、策略、部署和风险的 SHA-256 摘要,以及不可变 `cycle_id` 和 UTC 有效窗口;不会保存原始
URL、账户、订单或任何凭据字段。

相同 `cycle_id` 与相同内容可通过 `reconcile_paper_admission` 返回 `RECONCILED`;同一 cycle
出现不同内容会 fail-closed 为冲突。`LIVE`、`SHADOW`、shadow receipt 提权和未知/敏感字段均被拒绝。
该契约不代表 paper 账户已连接,也不授予 P4/P6 执行权限。

UESP 只负责产生 `qsl.tqqq-forward-observation.v1`。本仓的适配器再把它与独立的
policy-gate receipt、风险摘要和 deployment bundle 摘要组成 v2 shadow input;因此研究层
不需要导入或理解券商、政策签名或运行环境。适配器本身也不会排程或写入账本:
Expand Down
18 changes: 18 additions & 0 deletions src/alpaca_platform/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@
validate_p5_default_parked_scheduler_status,
validate_p5_default_parked_scheduler_summary,
)
from .paper_admission import (
PAPER_ADMISSION_SCHEMA,
PAPER_ENVIRONMENT,
PaperAdmissionConflictError,
PaperAdmissionError,
build_paper_admission,
calculate_paper_admission_sha256,
reconcile_paper_admission,
validate_paper_admission,
)
from .shadow_ledger import (
FORWARD_OBSERVATION_SCHEMA,
POLICY_GATE_RECEIPT_SCHEMA,
Expand Down Expand Up @@ -70,6 +80,8 @@
"P5_DEFAULT_PARKED_SCHEDULER_SUMMARY_SCHEMA",
"P5_RISK_GATE_DECISION_ENVELOPE_SCHEMA",
"P5_RISK_GATE_DECISION_REFERENCE_SCHEMA",
"PAPER_ADMISSION_SCHEMA",
"PAPER_ENVIRONMENT",
"PERSISTENCE_RESULT_SCHEMA",
"POLICY_GATE_RECEIPT_SCHEMA",
"SCHEDULER_RESULT_SCHEMA",
Expand All @@ -82,6 +94,8 @@
"P5DefaultParkedSchedulerError",
"P5DefaultParkedSchedulerOutcome",
"P5ShadowArtifactSnapshot",
"PaperAdmissionConflictError",
"PaperAdmissionError",
"RestrictedP5ArtifactReadError",
"RestrictedP5ShadowArtifactReader",
"ShadowCycleOutcome",
Expand All @@ -92,21 +106,25 @@
"ShadowRiskGateDecisionProhibitedError",
"ShadowSchedulerError",
"build_p5_risk_gate_decision_reference",
"build_paper_admission",
"build_shadow_ledger_receipt",
"build_shadow_receipt_admission",
"build_tqqq_shadow_cycle_input",
"calculate_forward_observation_sha256",
"calculate_p5_default_parked_scheduler_deduplication_sha256",
"calculate_p5_risk_gate_decision_envelope_sha256",
"calculate_paper_admission_sha256",
"calculate_policy_gate_receipt_sha256",
"calculate_shadow_receipt_admission_sha256",
"persist_shadow_cycle_outcome",
"reconcile_paper_admission",
"run_p5_default_parked_shadow_cycle",
"run_tqqq_shadow_cycle",
"summarize_p5_default_parked_scheduler_status",
"validate_p5_default_parked_scheduler_status",
"validate_p5_default_parked_scheduler_summary",
"validate_p5_risk_gate_decision_reference",
"validate_paper_admission",
"validate_shadow_cycle_input",
"validate_shadow_ledger_receipt",
"validate_shadow_receipt_admission",
Expand Down
180 changes: 180 additions & 0 deletions src/alpaca_platform/paper_admission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"""Local, fail-closed admission contract for Alpaca paper dry-runs."""

from __future__ import annotations

import hashlib
import json
import re
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any

PAPER_ADMISSION_SCHEMA = "qsl.alpaca_paper_admission.v1"
PAPER_ENVIRONMENT = "PAPER_DRY_RUN"

_DIGEST = re.compile(r"[0-9a-f]{64}")
_IDENTITY = re.compile(r"[a-z0-9][a-z0-9._:-]{0,127}")
_TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z")
_FORBIDDEN_KEY = re.compile(
r"(?:account|order|credential|secret|token|password|authorization|shadow)",
re.IGNORECASE,
)
_FIELDS = {
"schema",
"environment",
"cycle_id",
"valid_from",
"valid_until",
"paper_endpoint_sha256",
"config_sha256",
"strategy_sha256",
"deployment_sha256",
"risk_sha256",
"admission_sha256",
}
_BODY_FIELDS = _FIELDS - {"admission_sha256"}


class PaperAdmissionError(ValueError):
"""Raised when a paper admission is invalid or unsafe."""


class PaperAdmissionConflictError(PaperAdmissionError):
"""Raised when one cycle id is associated with different admission data."""


def _fail(message: str) -> None:
raise PaperAdmissionError(message)


def _object(value: Any, label: str) -> Mapping[str, Any]:
if not isinstance(value, Mapping):
_fail(f"{label} must be an object")
return value


def _check_keys(value: Mapping[str, Any], required: set[str], allowed: set[str], label: str) -> None:
keys = set(value)
if any(not isinstance(key, str) for key in keys):
_fail(f"{label} contains a non-string field name")
missing = sorted(required - keys)
unknown = sorted(keys - allowed)
if missing:
_fail(f"{label} missing required field(s): {', '.join(missing)}")
if unknown:
if any(_FORBIDDEN_KEY.search(key) for key in unknown):
_fail(f"{label} contains a forbidden field")
_fail(f"{label} contains an unknown field")


def _exact_keys(value: Mapping[str, Any], expected: set[str], label: str) -> None:
_check_keys(value, expected, expected, label)


def _digest(value: Any, label: str) -> str:
if not isinstance(value, str) or not _DIGEST.fullmatch(value):
_fail(f"{label} must be a lowercase SHA-256 digest")
return value


def _timestamp(value: Any, label: str) -> datetime:
if not isinstance(value, str) or not _TIMESTAMP.fullmatch(value):
_fail(f"{label} must be an RFC3339 UTC timestamp with whole seconds")
try:
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
except ValueError as exc:
raise PaperAdmissionError(f"{label} must be a valid calendar timestamp") from exc


def _canonical_json(value: Mapping[str, Any]) -> str:
content = dict(value)
content.pop("admission_sha256", None)
try:
return json.dumps(content, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False)
except (TypeError, ValueError) as exc:
raise PaperAdmissionError("paper admission cannot be represented as canonical JSON") from exc


def calculate_paper_admission_sha256(value: Mapping[str, Any]) -> str:
"""Calculate the digest over all admission fields except its self-digest."""
admission = _object(value, "paper admission")
_check_keys(admission, _BODY_FIELDS, _BODY_FIELDS | {"admission_sha256"}, "paper admission")
return hashlib.sha256(_canonical_json(admission).encode("utf-8")).hexdigest()


def _validate_body(value: Mapping[str, Any]) -> None:
if value["schema"] != PAPER_ADMISSION_SCHEMA:
_fail("paper admission schema is unsupported")
if value["environment"] != PAPER_ENVIRONMENT:
_fail("paper admission environment is not PAPER_DRY_RUN")
if not isinstance(value["cycle_id"], str) or not _IDENTITY.fullmatch(value["cycle_id"]):
_fail("paper admission cycle_id must be an immutable lowercase identity")

valid_from = _timestamp(value["valid_from"], "paper admission valid_from")
valid_until = _timestamp(value["valid_until"], "paper admission valid_until")
if valid_from >= valid_until:
_fail("paper admission validity window must be increasing")

for field in (
"paper_endpoint_sha256",
"config_sha256",
"strategy_sha256",
"deployment_sha256",
"risk_sha256",
"admission_sha256",
):
_digest(value[field], f"paper admission {field}")


def validate_paper_admission(value: Any) -> dict[str, str]:
"""Validate and return a copy of a complete local paper admission."""
admission = _object(value, "paper admission")
_exact_keys(admission, _FIELDS, "paper admission")
_validate_body(admission)
if admission["admission_sha256"] != calculate_paper_admission_sha256(admission):
_fail("paper admission digest does not match its content")
return dict(admission)


def build_paper_admission(
*,
environment: str,
cycle_id: str,
valid_from: str,
valid_until: str,
paper_endpoint_sha256: str,
config_sha256: str,
strategy_sha256: str,
deployment_sha256: str,
risk_sha256: str,
) -> dict[str, str]:
"""Build a deterministic, non-executable paper admission."""
admission: dict[str, str] = {
"schema": PAPER_ADMISSION_SCHEMA,
"environment": environment,
"cycle_id": cycle_id,
"valid_from": valid_from,
"valid_until": valid_until,
"paper_endpoint_sha256": paper_endpoint_sha256,
"config_sha256": config_sha256,
"strategy_sha256": strategy_sha256,
"deployment_sha256": deployment_sha256,
"risk_sha256": risk_sha256,
"admission_sha256": "0" * 64,
}
_exact_keys(admission, _FIELDS, "paper admission")
_validate_body(admission)
admission["admission_sha256"] = calculate_paper_admission_sha256(admission)
return admission


def reconcile_paper_admission(existing: Any, candidate: Any) -> str:
"""Reconcile two local admissions for one idempotency key."""
current = validate_paper_admission(existing)
proposed = validate_paper_admission(candidate)
if (
current["cycle_id"] != proposed["cycle_id"]
or current["admission_sha256"] != proposed["admission_sha256"]
):
raise PaperAdmissionConflictError("paper admission cycle id already has different content")
return "RECONCILED"
112 changes: 112 additions & 0 deletions tests/test_paper_admission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from __future__ import annotations

import copy

import pytest

from alpaca_platform import paper_admission


def sha(character: str) -> str:
return character * 64


def valid_admission() -> dict[str, str]:
return paper_admission.build_paper_admission(
environment="PAPER_DRY_RUN",
cycle_id="alpaca_paper_20260904_001",
valid_from="2026-09-04T00:00:00Z",
valid_until="2026-09-05T00:00:00Z",
paper_endpoint_sha256=sha("1"),
config_sha256=sha("2"),
strategy_sha256=sha("3"),
deployment_sha256=sha("4"),
risk_sha256=sha("5"),
)


def test_build_and_validate_paper_admission():
admission = valid_admission()

assert admission["schema"] == paper_admission.PAPER_ADMISSION_SCHEMA
assert admission["environment"] == "PAPER_DRY_RUN"
assert paper_admission.validate_paper_admission(admission) == admission


def test_digest_calculation_allows_omitting_self_digest():
admission = valid_admission()
admission_without_digest = dict(admission)
del admission_without_digest["admission_sha256"]

assert paper_admission.calculate_paper_admission_sha256(admission_without_digest) == admission["admission_sha256"]


def test_same_cycle_and_content_is_idempotently_reconciled():
admission = valid_admission()
replay = copy.deepcopy(admission)

assert paper_admission.reconcile_paper_admission(admission, replay) == "RECONCILED"


def test_same_cycle_with_changed_digest_is_a_conflict():
admission = valid_admission()
conflicting = copy.deepcopy(admission)
conflicting["risk_sha256"] = sha("6")
conflicting["admission_sha256"] = paper_admission.calculate_paper_admission_sha256(conflicting)

with pytest.raises(paper_admission.PaperAdmissionConflictError):
paper_admission.reconcile_paper_admission(admission, conflicting)


@pytest.mark.parametrize("environment", ["LIVE", "live", "SHADOW", "PAPER"])
def test_non_paper_environment_is_rejected(environment: str):
admission = valid_admission()
admission["environment"] = environment
admission["admission_sha256"] = paper_admission.calculate_paper_admission_sha256(admission)

with pytest.raises(paper_admission.PaperAdmissionError):
paper_admission.validate_paper_admission(admission)


@pytest.mark.parametrize("forbidden_field", ["shadow_receipt", "account_id", "order_id", "credential"])
def test_shadow_and_sensitive_material_cannot_be_added(forbidden_field: str):
admission = valid_admission()
admission[forbidden_field] = "forbidden"

with pytest.raises(paper_admission.PaperAdmissionError):
paper_admission.validate_paper_admission(admission)


def test_raw_endpoint_and_missing_digest_are_rejected():
admission = valid_admission()
admission["endpoint"] = "https://paper.example.invalid"

with pytest.raises(paper_admission.PaperAdmissionError):
paper_admission.validate_paper_admission(admission)

admission = valid_admission()
del admission["risk_sha256"]
with pytest.raises(paper_admission.PaperAdmissionError):
paper_admission.validate_paper_admission(admission)


def test_window_and_cycle_id_are_fail_closed():
admission = valid_admission()
admission["valid_until"] = admission["valid_from"]
admission["admission_sha256"] = paper_admission.calculate_paper_admission_sha256(admission)
with pytest.raises(paper_admission.PaperAdmissionError):
paper_admission.validate_paper_admission(admission)

admission = valid_admission()
admission["cycle_id"] = "contains whitespace"
admission["admission_sha256"] = paper_admission.calculate_paper_admission_sha256(admission)
with pytest.raises(paper_admission.PaperAdmissionError):
paper_admission.validate_paper_admission(admission)


def test_tampered_admission_digest_is_rejected():
admission = valid_admission()
admission["admission_sha256"] = sha("f")

with pytest.raises(paper_admission.PaperAdmissionError):
paper_admission.validate_paper_admission(admission)