diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec68613..47a9807 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,4 +36,4 @@ jobs: - name: Enforce core branch coverage run: >- python tools/check_branch_coverage.py coverage.json --threshold 90 - artifacts broker contracts schemas engine matching state_machine ledger rules + artifacts broker contracts schemas engine matching state_machine ledger rules dividends diff --git a/README.md b/README.md index fd53970..3dd9834 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,24 @@ cash-per-share amount or receivable total, while a registered zero-holding entit valid zero-effect lifecycle event. Missing payment dates must be rejected by the upstream corporate-action bridge. +The independent evidence-bound dividend lifecycle uses explicit entitlement, issuer-conversion, +and payment phases under `EVIDENCED_PIT` valuation. Issuer conversion posts the issuer's published +payment amount; the published rate and amount are retained with an exact rational difference and +an `unverified_no_rounding_contract` relationship status because the source contract does not +define how those two printed values were rounded. No tolerance or inferred rounding mode is used. +Scenario execution makes no external-certification claim. Production execution verifies the +entitlement basis, verifies a payment policy when it first affects an amount, and separately +verifies the actual payment. Manifest `trusted_verification_scope` lists only those checked facts; +`market_admission_certified` remains false because this module does not assess complete market +admission. + +Manifest `1.1.0` dividend exports seal one ledger snapshot before writing. Opening positions, +external cash, marks, ordinary ledger events, PIT FX observations, valuations, and dividend phases +share one ordered operation sequence. Replay applies those business facts in order and uses final +marks, lots, balances, lifecycle state, transactions, NAV, and journal only as comparison targets. +All public dividend records recursively own immutable nested values, while `to_dict()` returns a +fresh mutable copy. + ## Deterministic replay `DeterministicRunEngine.replay` sorts by availability/event time and stable stream @@ -182,7 +200,7 @@ python -m pytest --cov=quant_execution --cov-branch --cov-report=term-missing \ --cov-report=json:coverage.json -q python -m coverage report --fail-under=80 python tools/check_branch_coverage.py coverage.json --threshold 90 \ - broker contracts schemas engine matching state_machine ledger rules artifacts + broker contracts schemas engine matching state_machine ledger rules artifacts dividends python benchmarks/benchmark_replay.py --workload matching --matching-events 10000000 \ --repeat 3 --require-rate 50000 --memory-limit-gib 16 --artifact-mode arrow \ --artifact-root /dedicated/m7-artifacts --artifact-retention keep \ diff --git a/pyproject.toml b/pyproject.toml index d9e3691..2e3a280 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "quant-execution" -version = "0.5.1" +version = "0.6.0" description = "Deterministic execution, matching, risk and ledger for PureSaber quant research" readme = "README.md" license = {text = "MIT"} @@ -40,6 +40,8 @@ schemas = [ { id = "puresaber.execution.account-snapshot", version = "1.1.0" }, { id = "puresaber.execution.run-result", version = "1.1.0" }, { id = "puresaber.execution.replay-artifact-manifest", version = "1.0.0" }, + { id = "puresaber.execution.replay-artifact-manifest", version = "1.1.0" }, + { id = "puresaber.execution.dividend-record", version = "1" }, ] lock-files = ["requirements.lock"] diff --git a/src/quant_execution/__init__.py b/src/quant_execution/__init__.py index a91deab..0fa4f50 100644 --- a/src/quant_execution/__init__.py +++ b/src/quant_execution/__init__.py @@ -2,8 +2,11 @@ from quant_execution.artifacts import ( ArrowReplayArtifactSink, + DividendReplayResult, StoredRunArtifacts, + export_dividend_run, load_stored_artifacts, + replay_dividend_run, ) from quant_execution.broker import DeterministicBroker, remaining_quantity from quant_execution.contracts import ( @@ -30,6 +33,21 @@ Side, TimeInForce, ) +from quant_execution.dividends import ( + DIVIDEND_RECORD_SCHEMA_ID, + DividendEntitlementBasis, + DividendExecutionMode, + DividendExecutionPhase, + DividendExecutionRecord, + DividendExecutionRequest, + DividendExposureItem, + DividendExposureSnapshot, + DividendLifecycleState, + DividendValuationRecord, + EntitlementEvidenceVerifier, + FxValuationMode, + PitFxObservationRecord, +) from quant_execution.engine import ( DeterministicRunEngine, ReplayError, @@ -77,11 +95,12 @@ ) from quant_execution.state_machine import ALLOWED_TRANSITIONS, transition_order -__version__ = "0.5.1" +__version__ = "0.6.0" __all__ = [ "ACCOUNT_SNAPSHOT_SCHEMA_ID", "ALLOWED_TRANSITIONS", + "DIVIDEND_RECORD_SCHEMA_ID", "FEE_SCHEMA_ID", "FILL_SCHEMA_ID", "FUNDING_SCHEMA_ID", @@ -102,11 +121,23 @@ "CryptoSpotRule", "DeterministicBroker", "DeterministicRunEngine", + "DividendEntitlementBasis", + "DividendExecutionMode", + "DividendExecutionPhase", + "DividendExecutionRecord", + "DividendExecutionRequest", + "DividendExposureItem", + "DividendExposureSnapshot", + "DividendLifecycleState", + "DividendReplayResult", + "DividendValuationRecord", + "EntitlementEvidenceVerifier", "ExactAccountLedger", "Fee", "Fill", "Funding", "FuturesRule", + "FxValuationMode", "L2MatchingModel", "LedgerEvent", "LedgerEventType", @@ -120,6 +151,7 @@ "OrderIntent", "OrderStatus", "OrderType", + "PitFxObservationRecord", "PortfolioRiskPolicy", "PortfolioRiskSnapshot", "PositionRiskSnapshot", @@ -140,10 +172,12 @@ "TimeInForce", "TradeBBOModel", "execution_payload", + "export_dividend_run", "get_arrow_schema", "get_json_schema", "load_stored_artifacts", "remaining_quantity", + "replay_dividend_run", "resolve_a_share_replay_status", "transition_order", "validate_arrow_table", diff --git a/src/quant_execution/_fixed.py b/src/quant_execution/_fixed.py index b604694..f9788f4 100644 --- a/src/quant_execution/_fixed.py +++ b/src/quant_execution/_fixed.py @@ -2,7 +2,9 @@ from __future__ import annotations +from collections.abc import Iterable from decimal import ROUND_DOWN, ROUND_HALF_EVEN, Decimal +from fractions import Fraction from quant_data_kit import FixedPoint from quant_data_kit.exceptions import ValidationError @@ -48,6 +50,70 @@ def aligned(parts) -> int: return Decimal((int(total < 0), digits, exponent)) +def sum_decimal_exact(values: Iterable[Decimal], start: Decimal = Decimal(0)) -> Decimal: + """Sum finite decimals exactly without consulting the ambient context.""" + + total = start + for value in values: + total = add_decimal_exact(total, value) + return total + + +def decimal_fraction(value: Decimal | FixedPoint | int) -> Fraction: + """Return the exact rational value of a finite decimal-like value.""" + + if isinstance(value, FixedPoint): + return Fraction(value.units, 10**value.scale) + if isinstance(value, int) and not isinstance(value, bool): + return Fraction(value) + if not isinstance(value, Decimal) or not value.is_finite(): + raise ValidationError("exact fraction conversion requires a finite decimal value") + parts = value.as_tuple() + coefficient = 0 + for digit in parts.digits: + coefficient = coefficient * 10 + digit + if parts.sign: + coefficient = -coefficient + exponent = int(parts.exponent) + return ( + Fraction(coefficient * 10**exponent) + if exponent >= 0 + else Fraction(coefficient, 10 ** (-exponent)) + ) + + +def fraction_decimal_exact(value: Fraction) -> Decimal: + """Convert a finite base-10 fraction to Decimal without rounding.""" + + if not isinstance(value, Fraction): + raise ValidationError("value must be a Fraction") + numerator = value.numerator + denominator = value.denominator + twos = 0 + fives = 0 + while denominator % 2 == 0: + denominator //= 2 + twos += 1 + while denominator % 5 == 0: + denominator //= 5 + fives += 1 + if denominator != 1: + raise ValidationError("exact decimal result is non-terminating") + scale = max(twos, fives) + coefficient = abs(numerator) * 2 ** (scale - twos) * 5 ** (scale - fives) + digits = tuple(int(digit) for digit in str(coefficient)) if coefficient else (0,) + return Decimal((int(numerator < 0), digits, -scale)) + + +def multiply_decimal_exact(*values: Decimal | FixedPoint | int) -> Decimal: + """Multiply finite decimal-like values exactly.""" + + result = Fraction(1) + for value in values: + result *= decimal_fraction(value) + return fraction_decimal_exact(result) + + def floor_to_scale(value: Decimal, scale: int) -> FixedPoint: return fixed(value, scale, rounding=ROUND_DOWN) diff --git a/src/quant_execution/artifacts.py b/src/quant_execution/artifacts.py index 187c70c..20dca18 100644 --- a/src/quant_execution/artifacts.py +++ b/src/quant_execution/artifacts.py @@ -10,22 +10,56 @@ import tempfile import threading import time -from collections.abc import Iterator, Mapping +from collections.abc import Callable, Iterator, Mapping from contextlib import suppress from dataclasses import dataclass -from datetime import datetime +from datetime import date, datetime from decimal import Decimal from pathlib import Path from typing import Final import pyarrow as pa from pyarrow import ipc +from quant_data_kit import ( + AssetClass, + CorporateActionEvent, + FixedPoint, + InstrumentSpec, + MarginMode, + MarkPriceEvent, +) from quant_data_kit.exceptions import ValidationError +from quant_data_kit.financial import DividendLifecycle, PitFxRate from quant_execution._json import fixed_token, string_token, utc_token -from quant_execution.contracts import Fee, Fill, LedgerTransaction, Order, OrderEvent, Settlement +from quant_execution.contracts import ( + Fee, + Fill, + Funding, + LedgerEventType, + LedgerTransaction, + LiquidityRole, + Order, + OrderEvent, + Posting, + Settlement, + Side, +) +from quant_execution.dividends import ( + DividendExecutionMode, + DividendExecutionPhase, + DividendExecutionRecord, + DividendExecutionRequest, + DividendValuationRecord, + EntitlementEvidenceVerifier, + FxValuationMode, + PitFxObservationRecord, + canonical_bytes, + dividend_record_bytes, + dividend_record_from_dict, +) -_STREAMS: Final = ( +_LEGACY_STREAMS: Final = ( "orders", "order_events", "fills", @@ -34,6 +68,10 @@ "ledger_transactions", "risk_events", ) +_STREAMS_BY_MANIFEST_VERSION: Final = { + "1.0.0": _LEGACY_STREAMS, + "1.1.0": (*_LEGACY_STREAMS, "dividend_records"), +} _SCHEMA = pa.schema( [ pa.field("sequence", pa.int64(), nullable=False), @@ -182,9 +220,11 @@ class StoredRunArtifacts: logical_sha256: Mapping[str, str] files: Mapping[str, Mapping[str, object]] manifest_sha256: str + schema_version: str + run_metadata: Mapping[str, object] def iter_payload_bytes(self, stream: str) -> Iterator[bytes]: - if stream not in _STREAMS: + if stream not in self.counts: raise ValidationError(f"unknown artifact stream: {stream}") path = self.root / f"{stream}.arrow" if not path.exists(): @@ -240,11 +280,16 @@ def _manifest_hash(payload: Mapping[str, object]) -> str: return hashlib.sha256(_canonical_manifest_bytes(unsigned)).hexdigest() -def load_stored_artifacts(root: str | Path) -> StoredRunArtifacts: - """Strictly verify a completed artifact directory before exposing its facts.""" - +def _load_stored_artifacts( + root: str | Path, + *, + manifest_name: str, + reject_failed: bool, +) -> StoredRunArtifacts: resolved = Path(root).resolve() - manifest_path = resolved / "manifest.json" + if reject_failed and (resolved / "FAILED.json").exists(): + raise ValidationError("artifact directory is marked FAILED") + manifest_path = resolved / manifest_name try: raw = manifest_path.read_bytes() payload = json.loads(raw) @@ -266,8 +311,10 @@ def load_stored_artifacts(root: str | Path) -> StoredRunArtifacts: raise ValidationError("artifact manifest fields changed") if not isinstance(payload.get("run_metadata"), dict) or not payload["run_metadata"]: raise ValidationError("artifact manifest contains no run metadata") - if payload.get("schema_version") != "1.0.0": + schema_version = payload.get("schema_version") + if schema_version not in _STREAMS_BY_MANIFEST_VERSION: raise ValidationError("artifact manifest schema version is unsupported") + streams = _STREAMS_BY_MANIFEST_VERSION[schema_version] if payload.get("artifact_format") != "puresaber.arrow-canonical-json.v1": raise ValidationError("artifact format is unsupported") if payload.get("complete") is not True: @@ -280,16 +327,16 @@ def load_stored_artifacts(root: str | Path) -> StoredRunArtifacts: counts = payload.get("counts") logical = payload.get("logical_sha256") files = payload.get("files") - if not isinstance(counts, dict) or set(counts) != set(_STREAMS): + if not isinstance(counts, dict) or set(counts) != set(streams): raise ValidationError("artifact counts changed shape") - if not isinstance(logical, dict) or set(logical) != set(_STREAMS): + if not isinstance(logical, dict) or set(logical) != set(streams): raise ValidationError("artifact logical hashes changed shape") if not isinstance(files, dict): raise ValidationError("artifact files must be an object") verified_counts: dict[str, int] = {} verified_logical: dict[str, str] = {} verified_files: dict[str, dict[str, object]] = {} - for stream in _STREAMS: + for stream in streams: count = counts[stream] logical_sha256 = logical[stream] if isinstance(count, bool) or not isinstance(count, int) or count < 0: @@ -362,6 +409,18 @@ def load_stored_artifacts(root: str | Path) -> StoredRunArtifacts: logical_sha256=verified_logical, files=verified_files, manifest_sha256=manifest_sha256, + schema_version=schema_version, + run_metadata=dict(payload["run_metadata"]), + ) + + +def load_stored_artifacts(root: str | Path) -> StoredRunArtifacts: + """Strictly verify a completed artifact directory before exposing its facts.""" + + return _load_stored_artifacts( + root, + manifest_name="manifest.json", + reject_failed=True, ) @@ -379,21 +438,27 @@ def __init__( *, batch_size: int = 65_536, queue_batches: int = 8, + manifest_schema_version: str = "1.0.0", ) -> None: if isinstance(batch_size, bool) or batch_size <= 0: raise ValidationError("batch_size must be a positive integer") if isinstance(queue_batches, bool) or queue_batches <= 0: raise ValidationError("queue_batches must be a positive integer") + if manifest_schema_version not in _STREAMS_BY_MANIFEST_VERSION: + raise ValidationError("artifact manifest schema version is unsupported") self.root = Path(root).resolve() self.root.mkdir(parents=True, exist_ok=False) + self._manifest_schema_version = manifest_schema_version + self._streams = _STREAMS_BY_MANIFEST_VERSION[manifest_schema_version] self._batch_size = batch_size - self._buffers: dict[str, list[bytes]] = {name: [] for name in _STREAMS} - self._counts = {name: 0 for name in _STREAMS} - self._digests = {name: _SequenceDigest() for name in _STREAMS} + self._buffers: dict[str, list[bytes]] = {name: [] for name in self._streams} + self._counts = {name: 0 for name in self._streams} + self._digests = {name: _SequenceDigest() for name in self._streams} self._queue: queue.Queue[object] = queue.Queue(maxsize=queue_batches) self._failure: BaseException | None = None self._closed = False self._sealed = False + self._poisoned = False self._staged: list[tuple[str, bytes]] | None = None self._writers: dict[str, ipc.RecordBatchStreamWriter] = {} self._files: dict[str, pa.NativeFile] = {} @@ -411,14 +476,20 @@ def counts(self) -> Mapping[str, int]: def append(self, stream: str, payload: bytes) -> None: if self._closed or self._sealed: raise RuntimeError("artifact sink no longer accepts records") - if stream not in self._buffers: - raise ValidationError(f"unknown artifact stream: {stream}") - if not isinstance(payload, bytes): - raise ValidationError("artifact payload must be canonical bytes") - if self._staged is not None: - self._staged.append((stream, payload)) - return - self._append_committed(stream, payload) + try: + if stream not in self._buffers: + raise ValidationError(f"unknown artifact stream: {stream}") + if not isinstance(payload, bytes): + raise ValidationError("artifact payload must be canonical bytes") + if self._staged is not None: + self._staged.append((stream, payload)) + return + self._append_committed(stream, payload) + except Exception: + if self._manifest_schema_version == "1.1.0": + self._poisoned = True + self.abort() + raise def begin(self) -> None: if self._staged is not None: @@ -432,8 +503,14 @@ def commit(self) -> None: if staged is None: raise RuntimeError("no artifact transaction is active") self._staged = None - for stream, payload in staged: - self._append_committed(stream, payload) + try: + for stream, payload in staged: + self._append_committed(stream, payload) + except Exception: + if self._manifest_schema_version == "1.1.0": + self._poisoned = True + self.abort() + raise def rollback(self) -> None: if self._staged is None: @@ -455,7 +532,12 @@ def logical_sha256(self, stream: str) -> str: raise ValidationError(f"unknown artifact stream: {stream}") return self._digests[stream].close() - def close(self, manifest: Mapping[str, object]) -> StoredRunArtifacts: + def close( + self, + manifest: Mapping[str, object], + *, + candidate_validator: Callable[[StoredRunArtifacts], None] | None = None, + ) -> StoredRunArtifacts: if self._closed: raise RuntimeError("artifact sink is already closed") try: @@ -467,12 +549,12 @@ def close(self, manifest: Mapping[str, object]) -> StoredRunArtifacts: "bytes": path.stat().st_size, "sha256": _sha256_file(path), } - for name in _STREAMS + for name in self._streams for path in (self.root / f"{name}.arrow",) if path.is_file() } completed = { - "schema_version": "1.0.0", + "schema_version": self._manifest_schema_version, "artifact_format": "puresaber.arrow-canonical-json.v1", "counts": dict(self._counts), "logical_sha256": logical, @@ -481,18 +563,25 @@ def close(self, manifest: Mapping[str, object]) -> StoredRunArtifacts: "run_metadata": dict(manifest), } completed["manifest_sha256"] = _manifest_hash(completed) + if self._manifest_schema_version == "1.1.0": + if candidate_validator is None: + raise ValidationError("manifest 1.1 requires candidate replay validation") + candidate_path = self.root / "manifest.candidate.json" + _write_no_clobber(candidate_path, _canonical_manifest_bytes(completed)) + candidate = _load_stored_artifacts( + self.root, + manifest_name=candidate_path.name, + reject_failed=False, + ) + candidate_validator(candidate) manifest_path = self.root / "manifest.json" _write_no_clobber(manifest_path, _canonical_manifest_bytes(completed)) + stored = load_stored_artifacts(self.root) + (self.root / "manifest.candidate.json").unlink(missing_ok=True) self._closed = True - return StoredRunArtifacts( - root=self.root, - manifest_path=manifest_path, - counts=dict(self._counts), - logical_sha256=logical, - files=files, - manifest_sha256=str(completed["manifest_sha256"]), - ) + return stored except Exception: + self._poisoned = True self.abort() raise @@ -582,6 +671,7 @@ def abort(self) -> None: "artifact_format": "puresaber.arrow-canonical-json.v1", "counts": dict(self._counts), "complete": False, + "poisoned": self._poisoned, } try: (self.root / "FAILED.json").write_text( @@ -651,14 +741,634 @@ def _enqueue(self, item: object) -> None: continue +@dataclass(frozen=True, slots=True) +class DividendReplayResult: + artifacts: StoredRunArtifacts + ledger: object + + +def _fixed_payload(value: FixedPoint) -> dict[str, int]: + return {"units": value.units, "scale": value.scale} + + +def _fixed_from_payload(value: object, field: str) -> FixedPoint: + if not isinstance(value, Mapping) or set(value) != {"units", "scale"}: + raise ValidationError(f"{field} must be a fixed-point object") + units = value["units"] + scale = value["scale"] + if isinstance(units, bool) or not isinstance(units, int): + raise ValidationError(f"{field}.units must be an integer") + if isinstance(scale, bool) or not isinstance(scale, int): + raise ValidationError(f"{field}.scale must be an integer") + return FixedPoint(units, scale) + + +def _time_payload(value: datetime) -> str: + return value.isoformat().replace("+00:00", "Z") + + +def _time_from_payload(value: object, field: str) -> datetime: + if not isinstance(value, str): + raise ValidationError(f"{field} must be an ISO-8601 timestamp") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValidationError(f"{field} must be an ISO-8601 timestamp") from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ValidationError(f"{field} must be timezone-aware") + return parsed + + +def _spec_payload(spec: InstrumentSpec) -> dict[str, object]: + return { + "instrument_id": spec.instrument_id, + "asset_class": spec.asset_class.value, + "product_type": spec.product_type, + "venue": spec.venue, + "native_symbol": spec.native_symbol, + "base_currency": spec.base_currency, + "quote_currency": spec.quote_currency, + "settlement_currency": spec.settlement_currency, + "price_tick": _fixed_payload(spec.price_tick), + "quantity_step": _fixed_payload(spec.quantity_step), + "contract_multiplier": _fixed_payload(spec.contract_multiplier), + "calendar_id": spec.calendar_id, + "margin_mode": spec.margin_mode.value, + "inverse": spec.inverse, + "effective_from": _time_payload(spec.effective_from), + "effective_to": ( + _time_payload(spec.effective_to) if spec.effective_to is not None else None + ), + "available_at": _time_payload(spec.available_at), + "superseded_at": ( + _time_payload(spec.superseded_at) if spec.superseded_at is not None else None + ), + "underlying_id": spec.underlying_id, + "expiry_date": spec.expiry_date.isoformat() if spec.expiry_date is not None else None, + "metadata": dict(sorted(spec.metadata.items())), + } + + +def _spec_from_payload(value: object) -> InstrumentSpec: + if not isinstance(value, Mapping): + raise ValidationError("instrument spec replay fact must be an object") + return InstrumentSpec( + instrument_id=value["instrument_id"], + asset_class=AssetClass(value["asset_class"]), + product_type=value["product_type"], + venue=value["venue"], + native_symbol=value["native_symbol"], + base_currency=value["base_currency"], + quote_currency=value["quote_currency"], + settlement_currency=value["settlement_currency"], + price_tick=_fixed_from_payload(value["price_tick"], "price_tick"), + quantity_step=_fixed_from_payload(value["quantity_step"], "quantity_step"), + contract_multiplier=_fixed_from_payload( + value["contract_multiplier"], "contract_multiplier" + ), + calendar_id=value["calendar_id"], + margin_mode=MarginMode(value["margin_mode"]), + inverse=value["inverse"], + effective_from=_time_from_payload(value["effective_from"], "effective_from"), + effective_to=( + _time_from_payload(value["effective_to"], "effective_to") + if value["effective_to"] is not None + else None + ), + available_at=_time_from_payload(value["available_at"], "available_at"), + superseded_at=( + _time_from_payload(value["superseded_at"], "superseded_at") + if value["superseded_at"] is not None + else None + ), + underlying_id=value["underlying_id"], + expiry_date=(date.fromisoformat(value["expiry_date"]) if value["expiry_date"] else None), + metadata=dict(value["metadata"]), + ) + + +def _transaction_from_payload(value: object) -> LedgerTransaction: + if not isinstance(value, Mapping): + raise ValidationError("ledger transaction replay fact must be an object") + postings = [] + for posting in value["postings"]: + postings.append( + Posting( + ledger_account=posting["ledger_account"], + currency=posting["currency"], + amount=_fixed_from_payload(posting["amount"], "posting.amount"), + instrument_id=posting["instrument_id"], + quantity_delta=( + _fixed_from_payload(posting["quantity_delta"], "posting.quantity_delta") + if posting["quantity_delta"] is not None + else None + ), + ) + ) + return LedgerTransaction( + transaction_id=value["transaction_id"], + idempotency_key=value["idempotency_key"], + event_time=_time_from_payload(value["event_time"], "event_time"), + event_type=LedgerEventType(value["event_type"]), + reference_id=value["reference_id"], + postings=tuple(postings), + ) + + +def _ledger_event_from_business_fact(value: object): + if not isinstance(value, Mapping): + raise ValidationError("ledger event business fact must be an object") + kind = value.get("event_kind") + payload = value.get("event") + if not isinstance(payload, Mapping): + raise ValidationError("ledger event payload must be an object") + if kind == "fill": + return Fill( + fill_id=payload["fill_id"], + order_id=payload["order_id"], + account_id=payload["account_id"], + strategy_id=payload["strategy_id"], + instrument_id=payload["instrument_id"], + side=Side(payload["side"]), + quantity=_fixed_from_payload(payload["quantity"], "fill.quantity"), + price=_fixed_from_payload(payload["price"], "fill.price"), + event_time=_time_from_payload(payload["event_time"], "fill.event_time"), + liquidity_role=LiquidityRole(payload["liquidity_role"]), + venue_trade_id=payload["venue_trade_id"], + ) + if kind == "fee": + return Fee( + fee_id=payload["fee_id"], + fill_id=payload["fill_id"], + account_id=payload["account_id"], + amount=_fixed_from_payload(payload["amount"], "fee.amount"), + currency=payload["currency"], + event_time=_time_from_payload(payload["event_time"], "fee.event_time"), + fee_type=payload["fee_type"], + ) + if kind == "funding": + return Funding( + funding_id=payload["funding_id"], + account_id=payload["account_id"], + instrument_id=payload["instrument_id"], + amount=_fixed_from_payload(payload["amount"], "funding.amount"), + currency=payload["currency"], + event_time=_time_from_payload(payload["event_time"], "funding.event_time"), + ) + if kind == "settlement": + return Settlement( + settlement_id=payload["settlement_id"], + account_id=payload["account_id"], + instrument_id=payload["instrument_id"], + amount=_fixed_from_payload(payload["amount"], "settlement.amount"), + currency=payload["currency"], + event_time=_time_from_payload(payload["event_time"], "settlement.event_time"), + settlement_type=payload["settlement_type"], + settlement_price=( + _fixed_from_payload(payload["settlement_price"], "settlement.settlement_price") + if payload.get("settlement_price") is not None + else None + ), + ) + if kind == "corporate_action": + return CorporateActionEvent( + event_id=payload["event_id"], + instrument_id=payload["instrument_id"], + event_time=_time_from_payload(payload["event_time"], "action.event_time"), + received_at=_time_from_payload(payload["received_at"], "action.received_at"), + available_at=_time_from_payload(payload["available_at"], "action.available_at"), + source=payload["source"], + trading_day=date.fromisoformat(payload["trading_day"]), + session_id=payload["session_id"], + sequence=payload["sequence"], + action_type=payload["action_type"], + effective_date=date.fromisoformat(payload["effective_date"]), + ratio=( + _fixed_from_payload(payload["ratio"], "action.ratio") + if payload["ratio"] is not None + else None + ), + cash_amount=( + _fixed_from_payload(payload["cash_amount"], "action.cash_amount") + if payload["cash_amount"] is not None + else None + ), + currency=payload["currency"], + ) + raise ValidationError("unsupported ledger event business fact") + + +def _mark_from_business_fact(value: object) -> MarkPriceEvent: + if not isinstance(value, Mapping): + raise ValidationError("mark business fact must be an object") + if value.get("event_type") != "mark_price": + raise ValidationError("mark business fact has an invalid event type") + return MarkPriceEvent( + event_id=value["event_id"], + instrument_id=value["instrument_id"], + event_time=_time_from_payload(value["event_time"], "mark.event_time"), + received_at=_time_from_payload(value["received_at"], "mark.received_at"), + available_at=_time_from_payload(value["available_at"], "mark.available_at"), + source=value["source"], + trading_day=date.fromisoformat(value["trading_day"]), + session_id=value["session_id"], + sequence=value["sequence"], + price=_fixed_from_payload(value["price"], "mark.price"), + ) + + +def _account_snapshot_payload(value) -> dict[str, object]: + return { + "account_id": value.account_id, + "event_time": _time_payload(value.event_time), + "base_currency": value.base_currency, + "cash_balances": { + key: _fixed_payload(item) for key, item in sorted(value.cash_balances.items()) + }, + "positions": {key: _fixed_payload(item) for key, item in sorted(value.positions.items())}, + "nav": _fixed_payload(value.nav), + "cost_basis": {key: _fixed_payload(item) for key, item in sorted(value.cost_basis.items())}, + "realized_pnl": { + key: _fixed_payload(item) for key, item in sorted(value.realized_pnl.items()) + }, + "unrealized_pnl": { + key: _fixed_payload(item) for key, item in sorted(value.unrealized_pnl.items()) + }, + "initial_margin": _fixed_payload(value.initial_margin), + "maintenance_margin": _fixed_payload(value.maintenance_margin), + "liquidation_required": value.liquidation_required, + } + + +def _dividend_state_payload(ledger) -> list[dict[str, object]]: + return [state.to_dict() for _, state in sorted(ledger._dividend_lifecycle_states.items())] + + +def _sealed_dividend_ledger(captured: Mapping[str, object]): + from quant_execution.ledger import ExactAccountLedger + + sealed = ExactAccountLedger( + account_id=captured["account_id"], + base_currency=captured["base_currency"], + instruments=captured["instruments"], + initial_cash=captured["initial_cash"], + money_scale=captured["money_scale"], + opened_at=captured["opened_at"], + dividend_execution_mode=captured["dividend_execution_mode"], + fx_valuation_mode=captured["fx_valuation_mode"], + entitlement_evidence_verifier=captured["entitlement_evidence_verifier"], + ) + sealed._restore_captured_state(captured["state"]) + return sealed + + +def _trusted_verification_scope(ledger) -> list[str]: + if ledger.dividend_execution_mode is not DividendExecutionMode.PRODUCTION_CERTIFIED: + return [] + phases = [ + record + for record in ledger._dividend_execution_records + if isinstance(record, DividendExecutionRecord) + ] + scope = [] + if any(record.phase is DividendExecutionPhase.ENTITLEMENT for record in phases): + scope.append("entitlement_basis") + if any(record.lifecycle_snapshot.get("payment_policy") is not None for record in phases): + scope.append("payment_policy") + if any(record.phase is DividendExecutionPhase.PAYMENT for record in phases): + scope.append("actual_dividend_payment") + return scope + + +def _dividend_run_metadata(ledger) -> dict[str, object]: + if ledger.dividend_execution_mode is None: + raise ValidationError("dividend export requires an explicit execution mode") + if ledger.fx_valuation_mode is not FxValuationMode.EVIDENCED_PIT: + raise ValidationError("dividend export requires EVIDENCED_PIT") + if not ledger._dividend_operation_log: + raise ValidationError("dividend export requires lifecycle records") + snapshot = ledger.snapshot(ledger._event_time) + transaction_bytes = [ledger_transaction_bytes(item) for item in ledger._transactions] + initial_count = len(ledger._initial_cash) + metadata = { + "kind": "puresaber.execution.dividend-run/1", + "market_admission_certified": False, + "trusted_verification_scope": _trusted_verification_scope(ledger), + "business_facts": list(ledger._dividend_replay_facts), + "initial_conditions": { + "account_id": ledger.account_id, + "base_currency": ledger.base_currency, + "money_scale": ledger.money_scale, + "opened_at": _time_payload(ledger._default_opened_at), + "initial_cash": { + key: _fixed_payload(value) for key, value in sorted(ledger._initial_cash.items()) + }, + "instruments": [_spec_payload(spec) for _, spec in sorted(ledger.instruments.items())], + "execution_mode": ledger.dividend_execution_mode.value, + "fx_valuation_mode": ledger.fx_valuation_mode.value, + "initial_transaction_count": initial_count, + }, + "final_facts": { + "event_time": _time_payload(ledger._event_time), + "marks": [ + { + "instrument_id": instrument_id, + "price": str(price), + "event_time": _time_payload(event_time), + "event_id": event_id, + } + for instrument_id, (price, event_time, event_id) in sorted(ledger._marks.items()) + ], + "position_lots": { + instrument_id: [ + {"date": lot_date.isoformat(), "quantity": str(quantity)} + for lot_date, quantity in lots + ] + for instrument_id, lots in sorted(ledger._position_lots.items()) + }, + "transaction_sha256": hashlib.sha256( + canonical_bytes([item.hex() for item in transaction_bytes]) + ).hexdigest(), + "dividend_states": _dividend_state_payload(ledger), + "account_snapshot": _account_snapshot_payload(snapshot), + "journal_sha256": ledger.journal_sha256, + }, + } + metadata["metadata_sha256"] = hashlib.sha256(canonical_bytes(metadata)).hexdigest() + return metadata + + +def export_dividend_run(ledger, root: str | Path) -> StoredRunArtifacts: + """Export an immutable manifest 1.1 run and publish only after full replay validation.""" + + captured = ledger.capture_dividend_export_state() + sealed = _sealed_dividend_ledger(captured) + metadata = _dividend_run_metadata(sealed) + transactions = tuple(sealed._transactions) + dividend_records = tuple(sealed._dividend_operation_log) + verifier = captured["entitlement_evidence_verifier"] + sink = ArrowReplayArtifactSink(root, manifest_schema_version="1.1.0") + try: + sink.begin() + for transaction in transactions: + sink.append("ledger_transactions", ledger_transaction_bytes(transaction)) + for record in dividend_records: + sink.append("dividend_records", dividend_record_bytes(record)) + sink.commit() + + def validate_candidate(candidate: StoredRunArtifacts) -> None: + replay_dividend_run( + candidate, + entitlement_evidence_verifier=verifier, + ) + + return sink.close(metadata, candidate_validator=validate_candidate) + except Exception: + sink.abort() + raise + + +def _validate_metadata( + metadata: Mapping[str, object], +) -> tuple[Mapping[str, object], Mapping[str, object]]: + if metadata.get("kind") != "puresaber.execution.dividend-run/1": + raise ValidationError("artifact is not a dividend replay run") + expected_hash = metadata.get("metadata_sha256") + unsigned = dict(metadata) + unsigned.pop("metadata_sha256", None) + if ( + not isinstance(expected_hash, str) + or hashlib.sha256(canonical_bytes(unsigned)).hexdigest() != expected_hash + ): + raise ValidationError("dividend run metadata hash mismatch") + initial = metadata.get("initial_conditions") + final = metadata.get("final_facts") + if not isinstance(initial, Mapping) or not isinstance(final, Mapping): + raise ValidationError("dividend run metadata is incomplete") + return initial, final + + +def replay_dividend_run( + source: str | Path | StoredRunArtifacts, + *, + entitlement_evidence_verifier: EntitlementEvidenceVerifier | None = None, +) -> DividendReplayResult: + """Rebuild a dividend ledger from initial conditions and ordered persisted facts.""" + + from quant_execution.ledger import ExactAccountLedger + + artifacts = source if isinstance(source, StoredRunArtifacts) else load_stored_artifacts(source) + if artifacts.schema_version != "1.1.0": + raise ValidationError("dividend replay requires manifest schema version 1.1.0") + initial, final = _validate_metadata(artifacts.run_metadata) + execution_mode = DividendExecutionMode(initial["execution_mode"]) + if ( + execution_mode is DividendExecutionMode.PRODUCTION_CERTIFIED + and entitlement_evidence_verifier is None + ): + raise ValidationError("production dividend replay requires a trusted verifier") + specs = [_spec_from_payload(item) for item in initial["instruments"]] + instruments = {item.instrument_id: item for item in specs} + initial_cash = { + key: _fixed_from_payload(value, f"initial_cash.{key}") + for key, value in initial["initial_cash"].items() + } + ledger = ExactAccountLedger( + account_id=initial["account_id"], + base_currency=initial["base_currency"], + instruments=instruments, + initial_cash=initial_cash, + money_scale=initial["money_scale"], + opened_at=_time_from_payload(initial["opened_at"], "opened_at"), + dividend_execution_mode=execution_mode, + fx_valuation_mode=FxValuationMode(initial["fx_valuation_mode"]), + entitlement_evidence_verifier=entitlement_evidence_verifier, + ) + transactions = [ + _transaction_from_payload(value) for value in artifacts.iter_json("ledger_transactions") + ] + initial_count = initial["initial_transaction_count"] + if initial_count != len(ledger.transactions): + raise ValidationError("initial transaction count changed during replay") + for index in range(initial_count): + if ledger_transaction_bytes(ledger.transactions[index]) != ledger_transaction_bytes( + transactions[index] + ): + raise ValidationError("initial transaction bytes changed during replay") + records = [ + dividend_record_from_dict(value) for value in artifacts.iter_json("dividend_records") + ] + business_facts = artifacts.run_metadata.get("business_facts") + if not isinstance(business_facts, list) or not all( + isinstance(item, Mapping) for item in business_facts + ): + raise ValidationError("ordered business facts are missing") + for item in business_facts: + sequence = item.get("operation_sequence") + if isinstance(sequence, bool) or not isinstance(sequence, int) or sequence < 0: + raise ValidationError("business fact operation sequence is invalid") + operations = [(item["operation_sequence"], "business_fact", item) for item in business_facts] + operations.extend((item.operation_sequence, "dividend_record", item) for item in records) + operations.sort(key=lambda item: item[0]) + if [item[0] for item in operations] != list(range(len(operations))): + raise ValidationError("replay operation sequence is not contiguous") + observation_sequences = [ + item.observation_sequence for item in records if isinstance(item, PitFxObservationRecord) + ] + if observation_sequences != list(range(len(observation_sequences))): + raise ValidationError("PIT FX observation sequence is not contiguous") + + cursor = initial_count + + def compare_transaction_slice(before: int, after: int, *, context: str) -> None: + nonlocal cursor + if before != cursor or after < before or after > len(transactions): + raise ValidationError(f"{context} transaction boundary is invalid") + actual_created = ledger.transactions[before:after] + expected_created = transactions[before:after] + if [ledger_transaction_bytes(item) for item in actual_created] != [ + ledger_transaction_bytes(item) for item in expected_created + ]: + raise ValidationError(f"{context} transaction bytes changed during replay") + cursor = after + + for _, operation_kind, expected in operations: + if operation_kind == "business_fact": + before = expected.get("transaction_count_before") + after = expected.get("transaction_count_after") + payload = expected.get("payload") + if ( + isinstance(before, bool) + or not isinstance(before, int) + or isinstance(after, bool) + or not isinstance(after, int) + or not isinstance(payload, Mapping) + ): + raise ValidationError("business fact is malformed") + kind = expected.get("kind") + if kind == "opening_position": + ledger.book_opening_position( + instrument_id=payload["instrument_id"], + quantity=_fixed_from_payload(payload["quantity"], "opening.quantity"), + average_cost=_fixed_from_payload( + payload["average_cost"], "opening.average_cost" + ), + acquired_on=date.fromisoformat(payload["acquired_on"]), + ) + elif kind == "external_cash": + ledger.book_external_cash( + transfer_id=payload["transfer_id"], + amount=_fixed_from_payload(payload["amount"], "external_cash.amount"), + currency=payload["currency"], + event_time=_time_from_payload( + payload["event_time"], "external_cash.event_time" + ), + ) + elif kind == "mark": + ledger.mark(_mark_from_business_fact(payload), create_snapshot=False) + elif kind == "ledger_event": + event = _ledger_event_from_business_fact(payload) + trading_day_value = payload.get("trading_day") + if isinstance(event, Fill): + if not isinstance(trading_day_value, str): + raise ValidationError("fill business fact requires trading_day") + ledger.apply_with_trading_day( + event, + trading_day=date.fromisoformat(trading_day_value), + create_snapshot=False, + ) + else: + if trading_day_value is not None: + raise ValidationError("non-fill business fact cannot set trading_day") + ledger.apply(event, create_snapshot=False) + else: + raise ValidationError("unsupported ordered business fact") + compare_transaction_slice(before, after, context="business fact") + if ledger._dividend_replay_facts[-1] != dict(expected): + raise ValidationError("business fact changed during replay") + continue + + if isinstance(expected, DividendExecutionRecord): + before = expected.transaction_count_before + after = expected.transaction_count_after + if before != cursor: + raise ValidationError("dividend phase transaction boundary is invalid") + lifecycle = DividendLifecycle.from_dict(expected.to_dict()["lifecycle_snapshot"]) + actual = ledger.apply_dividend_lifecycle( + DividendExecutionRequest( + lifecycle=lifecycle, + phase=DividendExecutionPhase(expected.phase), + cutoff=expected.cutoff, + entitlement_basis=expected.entitlement_basis, + ) + ) + compare_transaction_slice(before, after, context="dividend phase") + elif isinstance(expected, PitFxObservationRecord): + if expected.transaction_count != cursor: + raise ValidationError("PIT FX transaction boundary is invalid") + actual = ledger.observe_pit_fx(PitFxRate.from_dict(expected.to_dict()["rate_payload"])) + elif isinstance(expected, DividendValuationRecord): + if expected.transaction_count != cursor: + raise ValidationError("valuation transaction boundary is invalid") + actual = ledger.record_dividend_valuation(as_of=expected.as_of) + else: # pragma: no cover - closed union guarded by parser + raise ValidationError("unsupported dividend replay record") + if actual.to_dict() != expected.to_dict(): + raise ValidationError("dividend record changed during replay") + if cursor != len(transactions): + raise ValidationError("ledger transactions lack ordered business facts") + + expected_event_time = _time_from_payload(final["event_time"], "final.event_time") + if ledger._event_time != expected_event_time: + raise ValidationError("replayed ledger event time mismatch") + marks = final.get("marks") + actual_marks = [ + { + "instrument_id": instrument_id, + "price": str(price), + "event_time": _time_payload(event_time), + "event_id": event_id, + } + for instrument_id, (price, event_time, event_id) in sorted(ledger._marks.items()) + ] + if actual_marks != marks: + raise ValidationError("replayed mark state mismatch") + position_lots = final.get("position_lots") + actual_position_lots = { + instrument_id: [ + {"date": lot_date.isoformat(), "quantity": str(quantity)} for lot_date, quantity in lots + ] + for instrument_id, lots in sorted(ledger._position_lots.items()) + } + if actual_position_lots != position_lots: + raise ValidationError("replayed position lots mismatch") + transaction_hash = hashlib.sha256( + canonical_bytes([ledger_transaction_bytes(item).hex() for item in ledger.transactions]) + ).hexdigest() + if transaction_hash != final.get("transaction_sha256"): + raise ValidationError("replayed transaction sequence hash mismatch") + if _dividend_state_payload(ledger) != final.get("dividend_states"): + raise ValidationError("replayed dividend lifecycle state mismatch") + if _account_snapshot_payload(ledger.snapshot(ledger._event_time)) != final.get( + "account_snapshot" + ): + raise ValidationError("replayed account snapshot mismatch") + if ledger.journal_sha256 != final.get("journal_sha256"): + raise ValidationError("replayed ledger journal hash mismatch") + return DividendReplayResult(artifacts=artifacts, ledger=ledger) + + __all__ = [ "ArrowReplayArtifactSink", + "DividendReplayResult", "StoredRunArtifacts", + "export_dividend_run", "fee_bytes", "fill_bytes", "ledger_transaction_bytes", "load_stored_artifacts", "order_bytes", "order_event_bytes", + "replay_dividend_run", "settlement_bytes", ] diff --git a/src/quant_execution/dividends.py b/src/quant_execution/dividends.py new file mode 100644 index 0000000..698ce56 --- /dev/null +++ b/src/quant_execution/dividends.py @@ -0,0 +1,1663 @@ +"""Evidence-bound dividend lifecycle execution for exact account ledgers.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Iterator, Mapping, Sequence +from dataclasses import dataclass, replace +from datetime import datetime, timezone +from decimal import ROUND_DOWN, ROUND_HALF_EVEN, ROUND_HALF_UP, ROUND_UP, Decimal +from enum import Enum +from fractions import Fraction +from types import MappingProxyType +from typing import Protocol, runtime_checkable + +from quant_data_kit import FixedPoint, ensure_utc_datetime +from quant_data_kit.exceptions import ValidationError +from quant_data_kit.financial import ( + DividendLifecycle, + PaymentPolicy, + PitFxRate, + RoundingPolicy, + select_pit_fx, +) + +from quant_execution._fixed import ( + decimal, + decimal_fraction, + fraction_decimal_exact, + sum_decimal_exact, +) +from quant_execution.contracts import LedgerEventType, LedgerTransaction, Posting + +DIVIDEND_RECORD_SCHEMA_ID = "puresaber.execution.dividend-record/1" +DIVIDEND_JOURNAL_SCHEMA_ID = "puresaber.ledger-journal/2" +UTC = timezone.utc + + +class _FrozenMapping(Mapping[str, object]): + __slots__ = ("_values",) + + def __init__(self, values: Mapping[str, object]) -> None: + self._values = MappingProxyType({str(key): _freeze(value) for key, value in values.items()}) + + def __getitem__(self, key: str) -> object: + return self._values[key] + + def __iter__(self) -> Iterator[str]: + return iter(self._values) + + def __len__(self) -> int: + return len(self._values) + + def __deepcopy__(self, memo: dict[int, object]): + memo[id(self)] = self + return self + + def __eq__(self, other: object) -> bool: + return isinstance(other, Mapping) and _thaw(self) == _thaw(other) + + def __repr__(self) -> str: + return repr(self._values) + + +class _FrozenSequence(Sequence[object]): + __slots__ = ("_values",) + + def __init__(self, values: Sequence[object]) -> None: + self._values = tuple(_freeze(value) for value in values) + + def __getitem__(self, index): + return self._values[index] + + def __len__(self) -> int: + return len(self._values) + + def __deepcopy__(self, memo: dict[int, object]): + memo[id(self)] = self + return self + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, Sequence) + and not isinstance(other, (str, bytes, bytearray)) + and _thaw(self) == _thaw(other) + ) + + def __repr__(self) -> str: + return repr(self._values) + + +def _freeze(value: object) -> object: + if isinstance(value, _FrozenMapping | _FrozenSequence): + return value + if isinstance(value, Mapping): + return _FrozenMapping(value) + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return _FrozenSequence(value) + return value + + +def _thaw(value: object) -> object: + if isinstance(value, Mapping): + return {key: _thaw(item) for key, item in value.items()} + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [_thaw(item) for item in value] + return value + + +class DividendExecutionMode(str, Enum): + SCENARIO_ONLY = "scenario_only" + PRODUCTION_CERTIFIED = "production_certified" + + +class FxValuationMode(str, Enum): + LEGACY = "legacy" + EVIDENCED_PIT = "evidenced_pit" + + +class DividendExecutionPhase(str, Enum): + ENTITLEMENT = "entitlement" + ISSUER_CONVERSION = "issuer_conversion" + PAYMENT = "payment" + + +def _text(value: str, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValidationError(f"{field} must be a non-empty string") + return value.strip() + + +def _utc(value: datetime, field: str) -> datetime: + return ensure_utc_datetime(value, field=field) + + +def _timestamp(value: datetime) -> str: + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +def _parse_timestamp(value: str, field: str) -> datetime: + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except (AttributeError, ValueError) as exc: + raise ValidationError(f"{field} must be an ISO-8601 timestamp") from exc + return _utc(parsed, field) + + +def canonical_bytes(value: object) -> bytes: + return json.dumps( + value, + sort_keys=True, + ensure_ascii=True, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + + +def _sha256(value: object) -> str: + return hashlib.sha256(canonical_bytes(value)).hexdigest() + + +def _fixed_payload(value: FixedPoint) -> dict[str, int]: + return {"units": value.units, "scale": value.scale} + + +def _fixed_from_payload(value: object, field: str) -> FixedPoint: + if not isinstance(value, Mapping) or set(value) != {"units", "scale"}: + raise ValidationError(f"{field} must be a fixed-point object") + units = value["units"] + scale = value["scale"] + if isinstance(units, bool) or not isinstance(units, int): + raise ValidationError(f"{field}.units must be an integer") + if isinstance(scale, bool) or not isinstance(scale, int): + raise ValidationError(f"{field}.scale must be an integer") + return FixedPoint(units, scale) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class DividendEntitlementBasis: + account_id: str + dividend_id: str + instrument_id: str + ex_at: datetime + entitled_quantity: FixedPoint + available_at: datetime + captured_at: datetime + evidence_id: str + evidence_source: str + certification_ref: str | None = None + + def __post_init__(self) -> None: + object.__setattr__(self, "account_id", _text(self.account_id, "account_id")) + object.__setattr__(self, "dividend_id", _text(self.dividend_id, "dividend_id")) + object.__setattr__(self, "instrument_id", _text(self.instrument_id, "instrument_id")) + object.__setattr__(self, "ex_at", _utc(self.ex_at, "ex_at")) + object.__setattr__(self, "available_at", _utc(self.available_at, "available_at")) + object.__setattr__(self, "captured_at", _utc(self.captured_at, "captured_at")) + if not isinstance(self.entitled_quantity, FixedPoint) or self.entitled_quantity.units < 0: + raise ValidationError("entitled_quantity must be a nonnegative FixedPoint") + if self.available_at > self.captured_at: + raise ValidationError("BASIS_CAPTURE_PRECEDES_AVAILABILITY") + object.__setattr__(self, "evidence_id", _text(self.evidence_id, "evidence_id")) + object.__setattr__(self, "evidence_source", _text(self.evidence_source, "evidence_source")) + if self.certification_ref is not None: + object.__setattr__( + self, + "certification_ref", + _text(self.certification_ref, "certification_ref"), + ) + + def to_dict(self) -> dict[str, object]: + return { + "account_id": self.account_id, + "dividend_id": self.dividend_id, + "instrument_id": self.instrument_id, + "ex_at": _timestamp(self.ex_at), + "entitled_quantity": _fixed_payload(self.entitled_quantity), + "available_at": _timestamp(self.available_at), + "captured_at": _timestamp(self.captured_at), + "evidence_id": self.evidence_id, + "evidence_source": self.evidence_source, + "certification_ref": self.certification_ref, + } + + @classmethod + def from_dict(cls, value: object) -> DividendEntitlementBasis: + if not isinstance(value, Mapping): + raise ValidationError("entitlement_basis must be an object") + return cls( + account_id=value["account_id"], + dividend_id=value["dividend_id"], + instrument_id=value["instrument_id"], + ex_at=_parse_timestamp(value["ex_at"], "ex_at"), + entitled_quantity=_fixed_from_payload(value["entitled_quantity"], "entitled_quantity"), + available_at=_parse_timestamp(value["available_at"], "available_at"), + captured_at=_parse_timestamp(value["captured_at"], "captured_at"), + evidence_id=value["evidence_id"], + evidence_source=value["evidence_source"], + certification_ref=value.get("certification_ref"), + ) + + +@runtime_checkable +class EntitlementEvidenceVerifier(Protocol): + """Trusted production boundary for entitlement, policy, and payment evidence.""" + + def verify_entitlement_basis( + self, + *, + basis: DividendEntitlementBasis, + lifecycle: DividendLifecycle, + ) -> bool: ... + + def verify_payment_policy( + self, + *, + policy: PaymentPolicy, + lifecycle: DividendLifecycle, + ) -> bool: ... + + def verify_dividend_payment( + self, + *, + payment: object, + lifecycle: DividendLifecycle, + ) -> bool: ... + + +@dataclass(frozen=True, kw_only=True, slots=True) +class DividendExecutionRequest: + lifecycle: DividendLifecycle + phase: DividendExecutionPhase + cutoff: datetime + entitlement_basis: DividendEntitlementBasis | None = None + + def __post_init__(self) -> None: + if not isinstance(self.lifecycle, DividendLifecycle): + raise ValidationError("lifecycle must be a DividendLifecycle") + if not isinstance(self.phase, DividendExecutionPhase): + raise ValidationError("phase must be a DividendExecutionPhase") + object.__setattr__(self, "cutoff", _utc(self.cutoff, "cutoff")) + if self.entitlement_basis is not None and not isinstance( + self.entitlement_basis, DividendEntitlementBasis + ): + raise ValidationError("entitlement_basis has an invalid type") + + +@dataclass(frozen=True, kw_only=True, slots=True) +class DividendLifecycleState: + account_id: str + dividend_id: str + instrument_id: str + execution_mode: DividendExecutionMode + fx_valuation_mode: FxValuationMode + entitlement_basis: DividendEntitlementBasis + ledger_quantity_at_ex: FixedPoint + lifecycle_snapshot: Mapping[str, object] + declared_currency: str + declared_gross: FixedPoint + receivable_currency: str + receivable_gross: FixedPoint + payment_policy_id: str | None + phases: tuple[str, ...] + phase_fingerprints: tuple[tuple[str, str], ...] + transaction_ids: tuple[str, ...] + paid: bool + state_sha256: str + + def __post_init__(self) -> None: + object.__setattr__(self, "lifecycle_snapshot", _freeze(self.lifecycle_snapshot)) + + def to_dict(self, *, include_hash: bool = True) -> dict[str, object]: + payload: dict[str, object] = { + "account_id": self.account_id, + "dividend_id": self.dividend_id, + "instrument_id": self.instrument_id, + "execution_mode": self.execution_mode.value, + "fx_valuation_mode": self.fx_valuation_mode.value, + "entitlement_basis": self.entitlement_basis.to_dict(), + "ledger_quantity_at_ex": _fixed_payload(self.ledger_quantity_at_ex), + "lifecycle_snapshot": _thaw(self.lifecycle_snapshot), + "declared_currency": self.declared_currency, + "declared_gross": _fixed_payload(self.declared_gross), + "receivable_currency": self.receivable_currency, + "receivable_gross": _fixed_payload(self.receivable_gross), + "payment_policy_id": self.payment_policy_id, + "phases": list(self.phases), + "phase_fingerprints": [list(item) for item in self.phase_fingerprints], + "transaction_ids": list(self.transaction_ids), + "paid": self.paid, + } + if include_hash: + payload["state_sha256"] = self.state_sha256 + return payload + + @classmethod + def from_dict(cls, value: object) -> DividendLifecycleState: + if not isinstance(value, Mapping): + raise ValidationError("dividend lifecycle state must be an object") + return cls( + account_id=value["account_id"], + dividend_id=value["dividend_id"], + instrument_id=value["instrument_id"], + execution_mode=DividendExecutionMode(value["execution_mode"]), + fx_valuation_mode=FxValuationMode(value["fx_valuation_mode"]), + entitlement_basis=DividendEntitlementBasis.from_dict(value["entitlement_basis"]), + ledger_quantity_at_ex=_fixed_from_payload( + value["ledger_quantity_at_ex"], "ledger_quantity_at_ex" + ), + lifecycle_snapshot=dict(value["lifecycle_snapshot"]), + declared_currency=value["declared_currency"], + declared_gross=_fixed_from_payload(value["declared_gross"], "declared_gross"), + receivable_currency=value["receivable_currency"], + receivable_gross=_fixed_from_payload(value["receivable_gross"], "receivable_gross"), + payment_policy_id=value["payment_policy_id"], + phases=tuple(value["phases"]), + phase_fingerprints=tuple(tuple(item) for item in value["phase_fingerprints"]), + transaction_ids=tuple(value["transaction_ids"]), + paid=value["paid"], + state_sha256=value["state_sha256"], + ) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class DividendExecutionRecord: + account_id: str + dividend_id: str + instrument_id: str + phase: DividendExecutionPhase + phase_event_id: str + cutoff: datetime + economic_effective_at: datetime + available_at: datetime + applied_at: datetime + lifecycle_snapshot: Mapping[str, object] + lifecycle_snapshot_sha256: str + phase_fingerprint: str + parent_state_sha256: str + resulting_state_sha256: str + execution_mode: DividendExecutionMode + fx_valuation_mode: FxValuationMode + entitlement_basis: DividendEntitlementBasis | None + issuer_conversion_audit: Mapping[str, object] | None + transaction_ids: tuple[str, ...] + operation_sequence: int + transaction_count_before: int + transaction_count_after: int + schema: str = DIVIDEND_RECORD_SCHEMA_ID + record_kind: str = "phase_application" + + def __post_init__(self) -> None: + object.__setattr__(self, "lifecycle_snapshot", _freeze(self.lifecycle_snapshot)) + object.__setattr__( + self, + "issuer_conversion_audit", + ( + _freeze(self.issuer_conversion_audit) + if self.issuer_conversion_audit is not None + else None + ), + ) + + def to_dict(self) -> dict[str, object]: + return { + "schema": self.schema, + "record_kind": self.record_kind, + "account_id": self.account_id, + "dividend_id": self.dividend_id, + "instrument_id": self.instrument_id, + "phase": self.phase.value, + "phase_event_id": self.phase_event_id, + "cutoff": _timestamp(self.cutoff), + "economic_effective_at": _timestamp(self.economic_effective_at), + "available_at": _timestamp(self.available_at), + "applied_at": _timestamp(self.applied_at), + "lifecycle_snapshot": _thaw(self.lifecycle_snapshot), + "lifecycle_snapshot_sha256": self.lifecycle_snapshot_sha256, + "phase_fingerprint": self.phase_fingerprint, + "parent_state_sha256": self.parent_state_sha256, + "resulting_state_sha256": self.resulting_state_sha256, + "execution_mode": self.execution_mode.value, + "fx_valuation_mode": self.fx_valuation_mode.value, + "entitlement_basis": ( + self.entitlement_basis.to_dict() if self.entitlement_basis is not None else None + ), + "issuer_conversion_audit": ( + _thaw(self.issuer_conversion_audit) + if self.issuer_conversion_audit is not None + else None + ), + "transaction_ids": list(self.transaction_ids), + "operation_sequence": self.operation_sequence, + "transaction_count_before": self.transaction_count_before, + "transaction_count_after": self.transaction_count_after, + } + + @classmethod + def from_dict(cls, value: object) -> DividendExecutionRecord: + if not isinstance(value, Mapping): + raise ValidationError("dividend execution record must be an object") + if value.get("schema") != DIVIDEND_RECORD_SCHEMA_ID: + raise ValidationError("unsupported dividend record schema") + if value.get("record_kind") != "phase_application": + raise ValidationError("record is not a dividend phase application") + basis = value["entitlement_basis"] + return cls( + account_id=value["account_id"], + dividend_id=value["dividend_id"], + instrument_id=value["instrument_id"], + phase=DividendExecutionPhase(value["phase"]), + phase_event_id=value["phase_event_id"], + cutoff=_parse_timestamp(value["cutoff"], "cutoff"), + economic_effective_at=_parse_timestamp( + value["economic_effective_at"], "economic_effective_at" + ), + available_at=_parse_timestamp(value["available_at"], "available_at"), + applied_at=_parse_timestamp(value["applied_at"], "applied_at"), + lifecycle_snapshot=dict(value["lifecycle_snapshot"]), + lifecycle_snapshot_sha256=value["lifecycle_snapshot_sha256"], + phase_fingerprint=value["phase_fingerprint"], + parent_state_sha256=value["parent_state_sha256"], + resulting_state_sha256=value["resulting_state_sha256"], + execution_mode=DividendExecutionMode(value["execution_mode"]), + fx_valuation_mode=FxValuationMode(value["fx_valuation_mode"]), + entitlement_basis=( + DividendEntitlementBasis.from_dict(basis) if basis is not None else None + ), + issuer_conversion_audit=( + dict(value["issuer_conversion_audit"]) + if value["issuer_conversion_audit"] is not None + else None + ), + transaction_ids=tuple(value["transaction_ids"]), + operation_sequence=value["operation_sequence"], + transaction_count_before=value["transaction_count_before"], + transaction_count_after=value["transaction_count_after"], + ) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class PitFxObservationRecord: + observation_sequence: int + rate_payload: Mapping[str, object] + rate_fingerprint: str + operation_sequence: int + transaction_count: int + schema: str = DIVIDEND_RECORD_SCHEMA_ID + record_kind: str = "pit_fx_observation" + + def __post_init__(self) -> None: + object.__setattr__(self, "rate_payload", _freeze(self.rate_payload)) + + def to_dict(self) -> dict[str, object]: + return { + "schema": self.schema, + "record_kind": self.record_kind, + "observation_sequence": self.observation_sequence, + "rate_payload": _thaw(self.rate_payload), + "rate_fingerprint": self.rate_fingerprint, + "operation_sequence": self.operation_sequence, + "transaction_count": self.transaction_count, + } + + @classmethod + def from_dict(cls, value: object) -> PitFxObservationRecord: + if not isinstance(value, Mapping): + raise ValidationError("PIT FX observation record must be an object") + return cls( + observation_sequence=value["observation_sequence"], + rate_payload=dict(value["rate_payload"]), + rate_fingerprint=value["rate_fingerprint"], + operation_sequence=value["operation_sequence"], + transaction_count=value["transaction_count"], + ) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class DividendExposureItem: + dividend_id: str + instrument_id: str + currency: str + gross_receivable: FixedPoint + base_value: FixedPoint + tax_status: str + + def to_dict(self) -> dict[str, object]: + return { + "dividend_id": self.dividend_id, + "instrument_id": self.instrument_id, + "currency": self.currency, + "gross_receivable": _fixed_payload(self.gross_receivable), + "base_value": _fixed_payload(self.base_value), + "tax_status": self.tax_status, + } + + +@dataclass(frozen=True, kw_only=True, slots=True) +class DividendExposureSnapshot: + account_id: str + as_of: datetime + execution_mode: DividendExecutionMode + fx_valuation_mode: FxValuationMode + items: tuple[DividendExposureItem, ...] + gross_nav: FixedPoint + tax_status: str + estimated_net_cash: FixedPoint | None + + def to_dict(self) -> dict[str, object]: + return { + "account_id": self.account_id, + "as_of": _timestamp(self.as_of), + "execution_mode": self.execution_mode.value, + "fx_valuation_mode": self.fx_valuation_mode.value, + "items": [item.to_dict() for item in self.items], + "gross_nav": _fixed_payload(self.gross_nav), + "tax_status": self.tax_status, + "estimated_net_cash": ( + _fixed_payload(self.estimated_net_cash) + if self.estimated_net_cash is not None + else None + ), + } + + +@dataclass(frozen=True, kw_only=True, slots=True) +class DividendValuationRecord: + valuation_idempotency_key: str + as_of: datetime + execution_mode: DividendExecutionMode + fx_valuation_mode: FxValuationMode + selected_rates: tuple[Mapping[str, object], ...] + result_payload: Mapping[str, object] + result_sha256: str + operation_sequence: int + transaction_count: int + schema: str = DIVIDEND_RECORD_SCHEMA_ID + record_kind: str = "valuation" + + def __post_init__(self) -> None: + object.__setattr__( + self, + "selected_rates", + tuple(_freeze(item) for item in self.selected_rates), + ) + object.__setattr__(self, "result_payload", _freeze(self.result_payload)) + + def to_dict(self) -> dict[str, object]: + return { + "schema": self.schema, + "record_kind": self.record_kind, + "valuation_idempotency_key": self.valuation_idempotency_key, + "as_of": _timestamp(self.as_of), + "execution_mode": self.execution_mode.value, + "fx_valuation_mode": self.fx_valuation_mode.value, + "selected_rates": [_thaw(item) for item in self.selected_rates], + "result_payload": _thaw(self.result_payload), + "result_sha256": self.result_sha256, + "operation_sequence": self.operation_sequence, + "transaction_count": self.transaction_count, + } + + @classmethod + def from_dict(cls, value: object) -> DividendValuationRecord: + if not isinstance(value, Mapping): + raise ValidationError("dividend valuation record must be an object") + return cls( + valuation_idempotency_key=value["valuation_idempotency_key"], + as_of=_parse_timestamp(value["as_of"], "as_of"), + execution_mode=DividendExecutionMode(value["execution_mode"]), + fx_valuation_mode=FxValuationMode(value["fx_valuation_mode"]), + selected_rates=tuple(dict(item) for item in value["selected_rates"]), + result_payload=dict(value["result_payload"]), + result_sha256=value["result_sha256"], + operation_sequence=value["operation_sequence"], + transaction_count=value["transaction_count"], + ) + + +DividendRecord = DividendExecutionRecord | PitFxObservationRecord | DividendValuationRecord + + +def dividend_record_bytes(record: DividendRecord) -> bytes: + return canonical_bytes(record.to_dict()) + + +def dividend_record_from_dict(value: object) -> DividendRecord: + if not isinstance(value, Mapping): + raise ValidationError("dividend record must be an object") + kind = value.get("record_kind") + if kind == "phase_application": + return DividendExecutionRecord.from_dict(value) + if kind == "pit_fx_observation": + return PitFxObservationRecord.from_dict(value) + if kind == "valuation": + return DividendValuationRecord.from_dict(value) + raise ValidationError("unsupported dividend record kind") + + +def _fraction_text(value: str, field: str) -> Fraction: + try: + parsed = Decimal(value) + except Exception as exc: + raise ValidationError(f"{field} must be decimal text") from exc + if not parsed.is_finite(): + raise ValidationError(f"{field} must be finite") + return decimal_fraction(parsed) + + +def _fixed_exact(value: Fraction, scale: int, code: str) -> FixedPoint: + scaled = value * 10**scale + if scaled.denominator != 1: + raise ValidationError(code) + try: + return FixedPoint(scaled.numerator, scale) + except (OverflowError, ValueError, ValidationError) as exc: + raise ValidationError("FIXED_POINT_OVERFLOW") from exc + + +def _round_fraction(value: Fraction, decimal_places: int, mode: str) -> Fraction: + scaled = value * 10**decimal_places + sign = -1 if scaled < 0 else 1 + absolute = abs(scaled) + quotient, remainder = divmod(absolute.numerator, absolute.denominator) + if remainder: + if mode == ROUND_DOWN: + increment = False + elif mode == ROUND_UP: + increment = True + elif mode == ROUND_HALF_UP: + increment = remainder * 2 >= absolute.denominator + elif mode == ROUND_HALF_EVEN: + doubled = remainder * 2 + increment = doubled > absolute.denominator or ( + doubled == absolute.denominator and quotient % 2 == 1 + ) + else: + raise ValidationError("unsupported rounding mode") + if increment: + quotient += 1 + return Fraction(sign * quotient, 10**decimal_places) + + +def _certified_rounding_policy( + policy: PaymentPolicy | None, + *, + account_id: str, +) -> RoundingPolicy | None: + if policy is None: + return None + if policy.account_id != account_id: + raise ValidationError("PAYMENT_POLICY_ACCOUNT_MISMATCH") + if policy.certification_status != "certified": + return None + return policy.rounding + + +def _account_money( + value: Fraction, + *, + money_scale: int, + policy: RoundingPolicy | None, +) -> FixedPoint: + if policy is not None: + if policy.scope != "aggregate_account": + raise ValidationError("UNSUPPORTED_ROUNDING_SCOPE") + if policy.decimal_places > money_scale: + raise ValidationError("LEDGER_SCALE_TOO_COARSE") + value = _round_fraction(value, policy.decimal_places, policy.mode) + return _fixed_exact(value, money_scale, "ROUNDING_POLICY_REQUIRED") + + +def _published_account_amount( + lifecycle: DividendLifecycle, quantity: FixedPoint | Fraction +) -> Fraction: + amount = lifecycle.entitlement.approved_amount + exact_quantity = quantity if isinstance(quantity, Fraction) else decimal_fraction(quantity) + return ( + _fraction_text(amount.amount_text, "approved amount") + / _fraction_text(amount.source_unit_text, "approved source unit") + * exact_quantity + ) + + +def _conversion_account_amount( + lifecycle: DividendLifecycle, quantity: FixedPoint | Fraction +) -> Fraction: + conversion = lifecycle.conversion + if conversion is None: + raise ValidationError("ISSUER_CONVERSION_REQUIRED") + amount = conversion.published_payment_amount + exact_quantity = quantity if isinstance(quantity, Fraction) else decimal_fraction(quantity) + return ( + _fraction_text(amount.amount_text, "published payment amount") + / _fraction_text(amount.source_unit_text, "published payment source unit") + * exact_quantity + ) + + +def _fraction_payload(value: Fraction) -> dict[str, str]: + return { + "numerator": str(value.numerator), + "denominator": str(value.denominator), + } + + +def _issuer_conversion_audit( + lifecycle: DividendLifecycle, + account_quantity: Fraction, +) -> dict[str, object]: + conversion = lifecycle.conversion + if conversion is None: + raise ValidationError("ISSUER_CONVERSION_REQUIRED") + declared = lifecycle.entitlement.approved_amount + published = conversion.published_payment_amount + if declared.source_unit_name != published.source_unit_name: + raise ValidationError("ISSUER_CONVERSION_SOURCE_UNIT_MISMATCH") + declared_per_unit = _fraction_text(declared.amount_text, "approved amount") / _fraction_text( + declared.source_unit_text, "approved source unit" + ) + rate = _fraction_text(conversion.rate_text, "issuer FX rate") + rate_implied = declared_per_unit * rate + published_per_unit = _fraction_text( + published.amount_text, "published payment amount" + ) / _fraction_text(published.source_unit_text, "published payment source unit") + rate_implied_account = rate_implied * account_quantity + published_account = published_per_unit * account_quantity + return { + "relationship_status": "unverified_no_rounding_contract", + "normalized_source_unit_name": declared.source_unit_name, + "declared_amount": declared.to_dict(), + "rate_text": conversion.rate_text, + "published_payment_amount": published.to_dict(), + "declared_per_unit": _fraction_payload(declared_per_unit), + "rate_implied_payment_per_unit": _fraction_payload(rate_implied), + "published_payment_per_unit": _fraction_payload(published_per_unit), + "published_minus_rate_implied": _fraction_payload(published_per_unit - rate_implied), + "account_quantity": _fraction_payload(account_quantity), + "rate_implied_account_payment": _fraction_payload(rate_implied_account), + "published_account_payment": _fraction_payload(published_account), + "account_published_minus_rate_implied": _fraction_payload( + published_account - rate_implied_account + ), + } + + +def _require_trusted_payment_policy(ledger, lifecycle: DividendLifecycle) -> None: + if ( + ledger.dividend_execution_mode is not DividendExecutionMode.PRODUCTION_CERTIFIED + or lifecycle.payment_policy is None + ): + return + verify = getattr(ledger._entitlement_evidence_verifier, "verify_payment_policy", None) + if not callable(verify) or not verify( + policy=lifecycle.payment_policy, + lifecycle=lifecycle, + ): + raise ValidationError("PAYMENT_POLICY_EVIDENCE_NOT_CERTIFIED") + + +def _require_trusted_actual_payment(ledger, lifecycle: DividendLifecycle) -> None: + if ledger.dividend_execution_mode is not DividendExecutionMode.PRODUCTION_CERTIFIED: + return + verify = getattr(ledger._entitlement_evidence_verifier, "verify_dividend_payment", None) + if not callable(verify) or not verify( + payment=lifecycle.payment, + lifecycle=lifecycle, + ): + raise ValidationError("DIVIDEND_PAYMENT_EVIDENCE_NOT_CERTIFIED") + + +def _contract_quantity(ledger, basis: DividendEntitlementBasis) -> Fraction: + spec = ledger.instruments[basis.instrument_id] + return decimal_fraction(basis.entitled_quantity) * decimal_fraction(spec.contract_multiplier) + + +def _fact_available_at(fact) -> datetime: + return _parse_timestamp(fact.evidence.timing.available_at, "available_at") + + +def _fact_effective_at(fact) -> datetime: + return _parse_timestamp(fact.evidence.timing.effective_at, "effective_at") + + +def _validate_cutoff_snapshot(lifecycle: DividendLifecycle, cutoff: datetime) -> None: + facts = [ + lifecycle.proposal, + lifecycle.entitlement, + lifecycle.election, + lifecycle.conversion, + lifecycle.payment, + ] + if lifecycle.payment_policy is not None: + facts.append(lifecycle.payment_policy) + for fact in facts: + if fact is None: + continue + evidence = getattr(fact, "evidence", None) + if evidence is not None and _fact_available_at(fact) > cutoff: + raise ValidationError("FUTURE_DIVIDEND_FACT_IN_REQUEST") + + +def _lifecycle_prefix( + lifecycle: DividendLifecycle, phase: DividendExecutionPhase +) -> dict[str, object]: + payload = lifecycle.to_dict() + if phase is DividendExecutionPhase.ENTITLEMENT: + payload["election"] = None + payload["conversion"] = None + payload["payment"] = None + elif phase is DividendExecutionPhase.ISSUER_CONVERSION: + payload["payment"] = None + return payload + + +def _phase_event(lifecycle: DividendLifecycle, phase: DividendExecutionPhase): + if phase is DividendExecutionPhase.ENTITLEMENT: + return lifecycle.entitlement + if phase is DividendExecutionPhase.ISSUER_CONVERSION: + if lifecycle.election is None: + raise ValidationError("PAYMENT_ELECTION_REQUIRED") + return lifecycle.conversion or lifecycle.election + if lifecycle.payment is None: + raise ValidationError("DIVIDEND_PAYMENT_REQUIRED") + return lifecycle.payment + + +def _record_key(account_id: str, dividend_id: str, phase: DividendExecutionPhase) -> str: + return f"dividend:{account_id}:{dividend_id}:{phase.value}" + + +def _phase_hash( + *, + ledger, + lifecycle: DividendLifecycle, + phase: DividendExecutionPhase, + prefix: Mapping[str, object], + basis: DividendEntitlementBasis | None, + parent_hash: str, + ledger_quantity: FixedPoint | None, +) -> str: + payload: dict[str, object] = { + "schema": DIVIDEND_RECORD_SCHEMA_ID, + "execution_mode": ledger.dividend_execution_mode.value, + "fx_valuation_mode": ledger.fx_valuation_mode.value, + "account_id": ledger.account_id, + "dividend_id": lifecycle.dividend_id, + "instrument_id": lifecycle.instrument_id, + "phase": phase.value, + "parent_state_sha256": parent_hash, + "lifecycle_snapshot": dict(prefix), + } + if basis is not None: + payload["entitlement_basis"] = basis.to_dict() + if ledger_quantity is not None: + payload["ledger_quantity_at_ex"] = _fixed_payload(ledger_quantity) + return _sha256(payload) + + +def _state_with_hash(**values) -> DividendLifecycleState: + state = DividendLifecycleState(state_sha256="", **values) + return replace(state, state_sha256=_sha256(state.to_dict(include_hash=False))) + + +def _transaction( + *, + ledger, + lifecycle: DividendLifecycle, + phase: DividendExecutionPhase, + event_time: datetime, + postings: tuple[Posting, ...], +) -> LedgerTransaction: + reference = f"dividend:{lifecycle.dividend_id}:{phase.value}" + identity = _sha256( + { + "account_id": ledger.account_id, + "reference": reference, + "event_time": _timestamp(event_time), + } + )[:24] + return LedgerTransaction( + transaction_id=f"tx-dividend-{identity}", + idempotency_key=_record_key(ledger.account_id, lifecycle.dividend_id, phase), + event_time=event_time, + event_type=LedgerEventType.CORPORATE_ACTION, + reference_id=reference, + postings=postings, + ) + + +def _posting( + account: str, + currency: str, + amount: FixedPoint, + *, + dividend_id: str, + bind_dividend: bool = True, +) -> Posting: + return Posting( + ledger_account=account, + currency=currency, + amount=amount, + instrument_id=f"dividend:{dividend_id}" if bind_dividend else None, + ) + + +def _negate(value: FixedPoint) -> FixedPoint: + return FixedPoint(-value.units, value.scale) + + +def apply_dividend_lifecycle(ledger, request: DividendExecutionRequest) -> DividendExecutionRecord: + """Apply one lifecycle phase as a complete in-memory transaction.""" + + if not isinstance(request, DividendExecutionRequest): + raise ValidationError("request must be a DividendExecutionRequest") + ledger._require_mutable() + checkpoint = ledger.capture_state() + try: + return _apply_dividend_lifecycle(ledger, request) + except Exception: + ledger._restore_captured_state(checkpoint) + raise + + +def _apply_dividend_lifecycle(ledger, request: DividendExecutionRequest) -> DividendExecutionRecord: + lifecycle = request.lifecycle + phase = request.phase + basis = request.entitlement_basis + if ledger.dividend_execution_mode is None: + raise ValidationError("DIVIDEND_EXECUTION_MODE_REQUIRED") + if ledger.fx_valuation_mode is not FxValuationMode.EVIDENCED_PIT: + raise ValidationError("EVIDENCED_PIT_REQUIRED") + if lifecycle.instrument_id not in ledger.instruments: + raise ValidationError("UNKNOWN_DIVIDEND_INSTRUMENT") + if ( + phase is DividendExecutionPhase.ENTITLEMENT + and basis is not None + and ( + _fact_available_at(lifecycle.entitlement) > basis.ex_at + or basis.available_at > basis.ex_at + ) + ): + raise ValidationError("LATE_ENTITLEMENT_UNSUPPORTED") + _validate_cutoff_snapshot(lifecycle, request.cutoff) + + event = _phase_event(lifecycle, phase) + if phase is DividendExecutionPhase.ENTITLEMENT: + phase_facts = [lifecycle.entitlement] + if lifecycle.payment_policy is not None: + phase_facts.append(lifecycle.payment_policy) + elif phase is DividendExecutionPhase.ISSUER_CONVERSION: + phase_facts = [lifecycle.election] + if lifecycle.conversion is not None: + phase_facts.append(lifecycle.conversion) + if lifecycle.payment_policy is not None: + phase_facts.append(lifecycle.payment_policy) + else: + phase_facts = [ + fact + for fact in ( + lifecycle.election, + lifecycle.conversion, + lifecycle.payment_policy, + lifecycle.payment, + ) + if fact is not None + ] + economic_at = max(_fact_effective_at(item) for item in phase_facts) + available_at = max(_fact_available_at(item) for item in phase_facts) + if economic_at > request.cutoff: + raise ValidationError("DIVIDEND_FACT_NOT_EFFECTIVE_AT_CUTOFF") + + key = _record_key(ledger.account_id, lifecycle.dividend_id, phase) + prefix = _lifecycle_prefix(lifecycle, phase) + prior_record = ledger._dividend_execution_by_key.get(key) + if prior_record is not None: + prior_state = ledger._dividend_lifecycle_states.get( + (ledger.account_id, lifecycle.dividend_id) + ) + expected_basis = ( + basis + if phase is DividendExecutionPhase.ENTITLEMENT + else (prior_state.entitlement_basis if prior_state is not None else None) + ) + same = ( + _thaw(prior_record.lifecycle_snapshot) == prefix + and prior_record.entitlement_basis == expected_basis + ) + if same: + if prior_record.applied_at > request.cutoff: + raise ValidationError("DIVIDEND_PHASE_APPLIED_AFTER_CUTOFF") + return prior_record + raise ValidationError("DIVIDEND_PHASE_ID_REUSED") + + state_key = (ledger.account_id, lifecycle.dividend_id) + prior_state = ledger._dividend_lifecycle_states.get(state_key) + parent_hash = prior_state.state_sha256 if prior_state is not None else "0" * 64 + transaction_before = len(ledger._transactions) + postings: tuple[Posting, ...] = () + issuer_conversion_audit: Mapping[str, object] | None = None + + if phase is DividendExecutionPhase.ENTITLEMENT: + if prior_state is not None: + raise ValidationError("DIVIDEND_PHASE_ORDER_INVALID") + if basis is None: + raise ValidationError("ENTITLEMENT_BASIS_REQUIRED") + if ( + basis.account_id != ledger.account_id + or basis.dividend_id != lifecycle.dividend_id + or basis.instrument_id != lifecycle.instrument_id + ): + raise ValidationError("ENTITLEMENT_BASIS_IDENTITY_MISMATCH") + if basis.available_at > basis.ex_at or available_at > basis.ex_at: + raise ValidationError("LATE_ENTITLEMENT_UNSUPPORTED") + if request.cutoff < basis.ex_at: + raise ValidationError("ENTITLEMENT_CUTOFF_PRECEDES_EX_AT") + if economic_at > basis.ex_at: + raise ValidationError("DIVIDEND_FACT_NOT_EFFECTIVE_AT_PHASE") + if ledger._event_time > basis.ex_at: + raise ValidationError("LEDGER_PASSED_EX_AT") + if ledger.dividend_execution_mode is DividendExecutionMode.PRODUCTION_CERTIFIED: + verifier = ledger._entitlement_evidence_verifier + if ( + verifier is None + or basis.certification_ref is None + or not verifier.verify_entitlement_basis(basis=basis, lifecycle=lifecycle) + ): + raise ValidationError("ENTITLEMENT_EVIDENCE_NOT_CERTIFIED") + ledger_quantity_decimal = ledger._positions.get(lifecycle.instrument_id, Decimal(0)) + if ledger._is_derivative(ledger.instruments[lifecycle.instrument_id]): + raise ValidationError("UNSUPPORTED_DERIVATIVE_DIVIDEND") + if ledger_quantity_decimal < 0: + raise ValidationError("NEGATIVE_DIVIDEND_POSITION_UNSUPPORTED") + if ledger_quantity_decimal != decimal(basis.entitled_quantity): + raise ValidationError("ENTITLEMENT_BASIS_MISMATCH") + ledger_quantity = basis.entitled_quantity + _require_trusted_payment_policy(ledger, lifecycle) + policy = _certified_rounding_policy(lifecycle.payment_policy, account_id=ledger.account_id) + gross_fraction = _published_account_amount(lifecycle, _contract_quantity(ledger, basis)) + declared_gross = _account_money( + gross_fraction, money_scale=ledger.money_scale, policy=policy + ) + declared_currency = lifecycle.entitlement.declared_currency.calculation_currency + applied_at = basis.ex_at + if declared_gross.units: + postings = ( + _posting( + "assets:dividend_receivable", + declared_currency, + declared_gross, + dividend_id=lifecycle.dividend_id, + ), + _posting( + "income:dividend", + declared_currency, + _negate(declared_gross), + dividend_id=lifecycle.dividend_id, + ), + ) + phase_fingerprint = _phase_hash( + ledger=ledger, + lifecycle=lifecycle, + phase=phase, + prefix=prefix, + basis=basis, + parent_hash=parent_hash, + ledger_quantity=ledger_quantity, + ) + next_state = _state_with_hash( + account_id=ledger.account_id, + dividend_id=lifecycle.dividend_id, + instrument_id=lifecycle.instrument_id, + execution_mode=ledger.dividend_execution_mode, + fx_valuation_mode=ledger.fx_valuation_mode, + entitlement_basis=basis, + ledger_quantity_at_ex=ledger_quantity, + lifecycle_snapshot=prefix, + declared_currency=declared_currency, + declared_gross=declared_gross, + receivable_currency=declared_currency, + receivable_gross=declared_gross, + payment_policy_id=( + lifecycle.payment_policy.policy_id if lifecycle.payment_policy is not None else None + ), + phases=(phase.value,), + phase_fingerprints=((phase.value, phase_fingerprint),), + transaction_ids=(), + paid=False, + ) + else: + if prior_state is None: + raise ValidationError("DIVIDEND_PHASE_ORDER_INVALID") + if prior_state.instrument_id != lifecycle.instrument_id: + raise ValidationError("LIFECYCLE_PREFIX_CHANGED") + prior_entitlement = _thaw(prior_state.lifecycle_snapshot["entitlement"]) + if prior_entitlement != lifecycle.entitlement.to_dict(): + raise ValidationError("LIFECYCLE_PREFIX_CHANGED") + prior_proposal = _thaw(prior_state.lifecycle_snapshot.get("proposal")) + current_proposal = lifecycle.proposal.to_dict() if lifecycle.proposal is not None else None + if prior_proposal is not None and current_proposal != prior_proposal: + raise ValidationError("LIFECYCLE_PREFIX_CHANGED") + prior_policy = _thaw(prior_state.lifecycle_snapshot.get("payment_policy")) + current_policy = ( + lifecycle.payment_policy.to_dict() if lifecycle.payment_policy is not None else None + ) + if prior_policy is not None and current_policy != prior_policy: + raise ValidationError("LIFECYCLE_PREFIX_CHANGED") + applied_at = max(economic_at, available_at) + if applied_at < ledger._event_time: + raise ValidationError("LEDGER_EVENT_TIME_BACKWARDS") + + if phase is DividendExecutionPhase.ISSUER_CONVERSION: + if prior_state.phases != (DividendExecutionPhase.ENTITLEMENT.value,): + raise ValidationError("DIVIDEND_PHASE_ORDER_INVALID") + election = lifecycle.election + if election is None or election.account_id != ledger.account_id: + raise ValidationError("PAYMENT_ELECTION_ACCOUNT_MISMATCH") + declared = prior_state.declared_currency + selected = election.payment_currency + _require_trusted_payment_policy(ledger, lifecycle) + policy = _certified_rounding_policy( + lifecycle.payment_policy, account_id=ledger.account_id + ) + if selected == declared: + if lifecycle.conversion is not None: + raise ValidationError("SAME_CURRENCY_CONVERSION_FORBIDDEN") + payment_gross = prior_state.declared_gross + else: + conversion = lifecycle.conversion + if conversion is None: + raise ValidationError("ISSUER_CONVERSION_REQUIRED") + if conversion.from_currency != declared or conversion.to_currency != selected: + raise ValidationError("ISSUER_CONVERSION_CURRENCY_MISMATCH") + account_quantity = _contract_quantity(ledger, prior_state.entitlement_basis) + exact_payment = _conversion_account_amount(lifecycle, account_quantity) + issuer_conversion_audit = _issuer_conversion_audit( + lifecycle, + account_quantity, + ) + payment_gross = _account_money( + exact_payment, money_scale=ledger.money_scale, policy=policy + ) + if prior_state.declared_gross.units or payment_gross.units: + postings = ( + _posting( + "assets:dividend_receivable", + declared, + _negate(prior_state.declared_gross), + dividend_id=lifecycle.dividend_id, + ), + _posting( + "clearing:dividend_issuer_fx", + declared, + prior_state.declared_gross, + dividend_id=lifecycle.dividend_id, + ), + _posting( + "assets:dividend_receivable", + selected, + payment_gross, + dividend_id=lifecycle.dividend_id, + ), + _posting( + "clearing:dividend_issuer_fx", + selected, + _negate(payment_gross), + dividend_id=lifecycle.dividend_id, + ), + ) + phase_fingerprint = _phase_hash( + ledger=ledger, + lifecycle=lifecycle, + phase=phase, + prefix=prefix, + basis=None, + parent_hash=parent_hash, + ledger_quantity=None, + ) + next_state = _state_with_hash( + account_id=prior_state.account_id, + dividend_id=prior_state.dividend_id, + instrument_id=prior_state.instrument_id, + execution_mode=prior_state.execution_mode, + fx_valuation_mode=prior_state.fx_valuation_mode, + entitlement_basis=prior_state.entitlement_basis, + ledger_quantity_at_ex=prior_state.ledger_quantity_at_ex, + lifecycle_snapshot=prefix, + declared_currency=prior_state.declared_currency, + declared_gross=prior_state.declared_gross, + receivable_currency=selected, + receivable_gross=payment_gross, + payment_policy_id=( + lifecycle.payment_policy.policy_id + if lifecycle.payment_policy is not None + else prior_state.payment_policy_id + ), + phases=(*prior_state.phases, phase.value), + phase_fingerprints=( + *prior_state.phase_fingerprints, + (phase.value, phase_fingerprint), + ), + transaction_ids=prior_state.transaction_ids, + paid=False, + ) + else: + if prior_state.phases != ( + DividendExecutionPhase.ENTITLEMENT.value, + DividendExecutionPhase.ISSUER_CONVERSION.value, + ): + raise ValidationError("DIVIDEND_PHASE_ORDER_INVALID") + election = lifecycle.election + policy = lifecycle.payment_policy + payment = lifecycle.payment + if election is None or payment is None or policy is None: + raise ValidationError("UNVERIFIED_PAYMENT_POLICY") + _require_trusted_payment_policy(ledger, lifecycle) + _require_trusted_actual_payment(ledger, lifecycle) + if election.to_dict() != _thaw(prior_state.lifecycle_snapshot.get("election")): + raise ValidationError("LIFECYCLE_PREFIX_CHANGED") + prior_conversion = _thaw(prior_state.lifecycle_snapshot.get("conversion")) + current_conversion = ( + lifecycle.conversion.to_dict() if lifecycle.conversion is not None else None + ) + if current_conversion != prior_conversion: + raise ValidationError("LIFECYCLE_PREFIX_CHANGED") + if lifecycle.conversion is not None: + issuer_conversion_audit = _issuer_conversion_audit( + lifecycle, + _contract_quantity(ledger, prior_state.entitlement_basis), + ) + if ( + policy.certification_status != "certified" + or policy.account_id != ledger.account_id + or payment.account_id != ledger.account_id + ): + raise ValidationError("UNVERIFIED_PAYMENT_POLICY") + if prior_policy is not None and policy.to_dict() != prior_policy: + raise ValidationError("LIFECYCLE_PREFIX_CHANGED") + if payment.payment_currency != prior_state.receivable_currency: + raise ValidationError("PAYMENT_CURRENCY_MISMATCH") + gross = _fixed_exact( + _fraction_text(payment.gross_cash_text, "gross cash"), + ledger.money_scale, + "PAYMENT_AMOUNT_NOT_EXACT", + ) + if gross != prior_state.receivable_gross: + raise ValidationError("PAYMENT_GROSS_MISMATCH") + net = _fixed_exact( + _fraction_text(payment.net_cash_text, "net cash"), + ledger.money_scale, + "PAYMENT_AMOUNT_NOT_EXACT", + ) + withholding = _fixed_exact( + _fraction_text(payment.withholding_cash_text, "withholding cash"), + ledger.money_scale, + "PAYMENT_AMOUNT_NOT_EXACT", + ) + rounding = _fixed_exact( + _fraction_text(payment.rounding_adjustment_text, "rounding adjustment"), + ledger.money_scale, + "PAYMENT_AMOUNT_NOT_EXACT", + ) + payment_postings = [ + _posting( + "assets:cash", + payment.payment_currency, + net, + dividend_id=lifecycle.dividend_id, + bind_dividend=False, + ), + _posting( + "expenses:dividend_withholding", + payment.payment_currency, + withholding, + dividend_id=lifecycle.dividend_id, + ), + ] + payment_postings.extend( + _posting( + f"expenses:dividend_deduction:{deduction.deduction_id}", + payment.payment_currency, + _fixed_exact( + _fraction_text(deduction.amount_text, "deduction amount"), + ledger.money_scale, + "PAYMENT_AMOUNT_NOT_EXACT", + ), + dividend_id=lifecycle.dividend_id, + ) + for deduction in payment.deductions + ) + payment_postings.extend( + ( + _posting( + "expenses:dividend_rounding", + payment.payment_currency, + rounding, + dividend_id=lifecycle.dividend_id, + ), + _posting( + "assets:dividend_receivable", + payment.payment_currency, + _negate(gross), + dividend_id=lifecycle.dividend_id, + ), + ) + ) + if gross.units: + postings = tuple(payment_postings) + phase_fingerprint = _phase_hash( + ledger=ledger, + lifecycle=lifecycle, + phase=phase, + prefix=prefix, + basis=None, + parent_hash=parent_hash, + ledger_quantity=None, + ) + next_state = _state_with_hash( + account_id=prior_state.account_id, + dividend_id=prior_state.dividend_id, + instrument_id=prior_state.instrument_id, + execution_mode=prior_state.execution_mode, + fx_valuation_mode=prior_state.fx_valuation_mode, + entitlement_basis=prior_state.entitlement_basis, + ledger_quantity_at_ex=prior_state.ledger_quantity_at_ex, + lifecycle_snapshot=prefix, + declared_currency=prior_state.declared_currency, + declared_gross=prior_state.declared_gross, + receivable_currency=prior_state.receivable_currency, + receivable_gross=FixedPoint(0, ledger.money_scale), + payment_policy_id=policy.policy_id, + phases=(*prior_state.phases, phase.value), + phase_fingerprints=( + *prior_state.phase_fingerprints, + (phase.value, phase_fingerprint), + ), + transaction_ids=prior_state.transaction_ids, + paid=True, + ) + + transaction_ids: tuple[str, ...] = () + if postings: + transaction = _transaction( + ledger=ledger, + lifecycle=lifecycle, + phase=phase, + event_time=applied_at, + postings=postings, + ) + ledger._post(transaction) + transaction_ids = (transaction.transaction_id,) + ledger._event_time = applied_at + if transaction_ids: + next_state = replace( + next_state, + transaction_ids=(*next_state.transaction_ids, *transaction_ids), + state_sha256="", + ) + next_state = replace( + next_state, state_sha256=_sha256(next_state.to_dict(include_hash=False)) + ) + operation_sequence = ledger._next_dividend_replay_sequence() + record = DividendExecutionRecord( + account_id=ledger.account_id, + dividend_id=lifecycle.dividend_id, + instrument_id=lifecycle.instrument_id, + phase=phase, + phase_event_id=event.evidence.event_id, + cutoff=request.cutoff, + economic_effective_at=economic_at, + available_at=available_at, + applied_at=applied_at, + lifecycle_snapshot=prefix, + lifecycle_snapshot_sha256=_sha256(prefix), + phase_fingerprint=phase_fingerprint, + parent_state_sha256=parent_hash, + resulting_state_sha256=next_state.state_sha256, + execution_mode=ledger.dividend_execution_mode, + fx_valuation_mode=ledger.fx_valuation_mode, + entitlement_basis=next_state.entitlement_basis, + issuer_conversion_audit=issuer_conversion_audit, + transaction_ids=transaction_ids, + operation_sequence=operation_sequence, + transaction_count_before=transaction_before, + transaction_count_after=len(ledger._transactions), + ) + ledger._dividend_lifecycle_states[state_key] = next_state + ledger._dividend_execution_records.append(record) + ledger._dividend_execution_by_key[key] = record + ledger._dividend_phase_fingerprints[key] = phase_fingerprint + ledger._dividend_operation_log.append(record) + return record + + +def observe_pit_fx(ledger, rate: PitFxRate) -> PitFxObservationRecord: + if ledger.fx_valuation_mode is not FxValuationMode.EVIDENCED_PIT: + raise ValidationError("EVIDENCED_PIT_REQUIRED") + if not isinstance(rate, PitFxRate): + raise ValidationError("rate must be a PitFxRate") + ledger._require_mutable() + fingerprint = rate.fingerprint() + prior = ledger._dividend_pit_fx_by_event_id.get(rate.event_id) + if prior is not None: + if prior.fingerprint() != fingerprint: + raise ValidationError("PIT_FX_EVENT_ID_CONFLICT") + return next( + item + for item in ledger._dividend_pit_fx_records + if item.rate_payload["event_id"] == rate.event_id + ) + sequence = len(ledger._dividend_pit_fx_observations) + operation_sequence = ledger._next_dividend_replay_sequence() + record = PitFxObservationRecord( + observation_sequence=sequence, + rate_payload=rate.to_dict(), + rate_fingerprint=fingerprint, + operation_sequence=operation_sequence, + transaction_count=len(ledger._transactions), + ) + ledger._dividend_pit_fx_observations.append(rate) + ledger._dividend_pit_fx_by_event_id[rate.event_id] = rate + ledger._dividend_pit_fx_records.append(record) + ledger._dividend_operation_log.append(record) + return record + + +def select_valuation_rate(ledger, currency: str, as_of: datetime): + try: + return select_pit_fx( + ledger._dividend_pit_fx_observations, + base_currency=currency, + quote_currency=ledger.base_currency, + cutoff=_timestamp(as_of), + ) + except (TypeError, ValueError) as exc: + raise ValidationError(f"PIT_FX_UNAVAILABLE: {exc}") from exc + + +def convert_for_valuation( + ledger, amount: Decimal | FixedPoint, currency: str, as_of: datetime +) -> Decimal: + if ledger.fx_valuation_mode is FxValuationMode.LEGACY: + return ledger._to_base(amount, currency, as_of) + value = decimal(amount) if isinstance(amount, FixedPoint) else amount + if not isinstance(value, Decimal) or not value.is_finite(): + raise ValidationError("valuation amount must be a finite Decimal or FixedPoint") + if value == 0: + return Decimal(0) + selected = select_valuation_rate(ledger, currency, as_of) + try: + return fraction_decimal_exact(decimal_fraction(value) * decimal_fraction(selected.rate)) + except ValidationError as exc: + raise ValidationError("PIT_VALUATION_RESULT_NOT_EXACT") from exc + + +def dividend_exposure(ledger, *, as_of: datetime) -> DividendExposureSnapshot: + at = _utc(as_of, "as_of") + if at < ledger._event_time: + raise ValidationError("HISTORICAL_LEDGER_STATE_UNAVAILABLE") + items: list[DividendExposureItem] = [] + base_values: list[Decimal] = [] + unknown = False + for state in sorted( + ledger._dividend_lifecycle_states.values(), key=lambda item: item.dividend_id + ): + if state.receivable_gross.units == 0: + continue + value = convert_for_valuation(ledger, state.receivable_gross, state.receivable_currency, at) + base_value = _fixed_exact( + decimal_fraction(value), ledger.money_scale, "PIT_VALUATION_PRECISION_REQUIRED" + ) + policy_payload = state.lifecycle_snapshot.get("payment_policy") + certified = bool( + isinstance(policy_payload, Mapping) + and policy_payload.get("certification_status") == "certified" + ) + unknown = unknown or not certified + items.append( + DividendExposureItem( + dividend_id=state.dividend_id, + instrument_id=state.instrument_id, + currency=state.receivable_currency, + gross_receivable=state.receivable_gross, + base_value=base_value, + tax_status="certified" if certified else "unknown", + ) + ) + base_values.append(value) + gross = _fixed_exact( + decimal_fraction(sum_decimal_exact(base_values)), + ledger.money_scale, + "PIT_VALUATION_PRECISION_REQUIRED", + ) + return DividendExposureSnapshot( + account_id=ledger.account_id, + as_of=at, + execution_mode=ledger.dividend_execution_mode, + fx_valuation_mode=ledger.fx_valuation_mode, + items=tuple(items), + gross_nav=gross, + tax_status="unknown" if unknown else "certified", + estimated_net_cash=None, + ) + + +def _selected_rate_payloads(ledger, as_of: datetime) -> tuple[Mapping[str, object], ...]: + currencies = { + currency + for (account, currency, _), amount in ledger._accounts.items() + if amount != 0 and account in {"assets:cash", "assets:dividend_receivable"} + } + currencies.update( + ledger.instruments[instrument_id].settlement_currency + for instrument_id, quantity in ledger._positions.items() + if quantity != 0 + ) + payloads = [] + for currency in sorted(currencies): + selected = select_valuation_rate(ledger, currency, as_of) + if selected.identity: + payloads.append( + { + "base_currency": selected.base_currency, + "quote_currency": selected.quote_currency, + "rate_text": "1", + "identity": True, + "event_id": None, + "observed_at": None, + "available_at": None, + "captured_at": None, + "source": "identity", + "evidence_id": "identity", + } + ) + else: + full = ledger._dividend_pit_fx_by_event_id[selected.event_id].to_dict() + payloads.append({**full, "identity": False}) + return tuple(payloads) + + +def record_dividend_valuation(ledger, *, as_of: datetime) -> DividendValuationRecord: + if ledger.fx_valuation_mode is not FxValuationMode.EVIDENCED_PIT: + raise ValidationError("EVIDENCED_PIT_REQUIRED") + ledger._require_mutable() + at = _utc(as_of, "as_of") + exposure = dividend_exposure(ledger, as_of=at) + account = ledger.snapshot(at) + result_payload = { + "account_snapshot": { + "account_id": account.account_id, + "event_time": _timestamp(account.event_time), + "base_currency": account.base_currency, + "cash_balances": { + key: _fixed_payload(value) for key, value in account.cash_balances.items() + }, + "positions": {key: _fixed_payload(value) for key, value in account.positions.items()}, + "nav": _fixed_payload(account.nav), + "cost_basis": {key: _fixed_payload(value) for key, value in account.cost_basis.items()}, + "realized_pnl": { + key: _fixed_payload(value) for key, value in account.realized_pnl.items() + }, + "unrealized_pnl": { + key: _fixed_payload(value) for key, value in account.unrealized_pnl.items() + }, + "initial_margin": _fixed_payload(account.initial_margin), + "maintenance_margin": _fixed_payload(account.maintenance_margin), + "liquidation_required": account.liquidation_required, + }, + "dividend_exposure": exposure.to_dict(), + } + selected = _selected_rate_payloads(ledger, at) + key = f"dividend-valuation:{ledger.account_id}:{_timestamp(at)}" + result_sha = _sha256(result_payload) + prior = ledger._recorded_dividend_valuations.get(key) + if prior is not None: + if ( + prior.result_sha256 == result_sha + and prior.selected_rates == selected + and _thaw(prior.result_payload) == result_payload + ): + return prior + raise ValidationError("VALUATION_IDEMPOTENCY_CONFLICT") + record = DividendValuationRecord( + valuation_idempotency_key=key, + as_of=at, + execution_mode=ledger.dividend_execution_mode, + fx_valuation_mode=ledger.fx_valuation_mode, + selected_rates=selected, + result_payload=result_payload, + result_sha256=result_sha, + operation_sequence=ledger._next_dividend_replay_sequence(), + transaction_count=len(ledger._transactions), + ) + ledger._recorded_dividend_valuations[key] = record + ledger._dividend_valuation_records.append(record) + ledger._dividend_operation_log.append(record) + return record + + +__all__ = [ + "DIVIDEND_JOURNAL_SCHEMA_ID", + "DIVIDEND_RECORD_SCHEMA_ID", + "DividendEntitlementBasis", + "DividendExecutionMode", + "DividendExecutionPhase", + "DividendExecutionRecord", + "DividendExecutionRequest", + "DividendExposureItem", + "DividendExposureSnapshot", + "DividendLifecycleState", + "DividendValuationRecord", + "EntitlementEvidenceVerifier", + "FxValuationMode", + "PitFxObservationRecord", + "apply_dividend_lifecycle", + "canonical_bytes", + "convert_for_valuation", + "dividend_exposure", + "dividend_record_bytes", + "dividend_record_from_dict", + "observe_pit_fx", + "record_dividend_valuation", + "select_valuation_rate", +] diff --git a/src/quant_execution/ledger.py b/src/quant_execution/ledger.py index b6a72ee..4b8ceeb 100644 --- a/src/quant_execution/ledger.py +++ b/src/quant_execution/ledger.py @@ -29,7 +29,15 @@ ) from quant_data_kit.exceptions import ValidationError -from quant_execution._fixed import add_decimal_exact, decimal, fixed +from quant_execution._fixed import ( + add_decimal_exact, + decimal, + decimal_fraction, + fixed, + fraction_decimal_exact, + multiply_decimal_exact, + sum_decimal_exact, +) from quant_execution._json import fixed_token, flat_sequence_bytes, string_token, utc_token from quant_execution.artifacts import ( fee_bytes, @@ -52,6 +60,32 @@ Side, _currency, ) +from quant_execution.dividends import ( + DIVIDEND_JOURNAL_SCHEMA_ID, + DividendExecutionMode, + DividendExecutionRequest, + DividendExposureSnapshot, + DividendValuationRecord, + EntitlementEvidenceVerifier, + FxValuationMode, + PitFxObservationRecord, + canonical_bytes, +) +from quant_execution.dividends import ( + apply_dividend_lifecycle as apply_dividend_lifecycle_request, +) +from quant_execution.dividends import ( + convert_for_valuation as convert_dividend_value, +) +from quant_execution.dividends import ( + dividend_exposure as build_dividend_exposure, +) +from quant_execution.dividends import ( + observe_pit_fx as observe_dividend_pit_fx, +) +from quant_execution.dividends import ( + record_dividend_valuation as build_dividend_valuation, +) from quant_execution.schemas import execution_payload UTC = timezone.utc @@ -100,11 +134,34 @@ def __init__( fx_to_base: Mapping[str, FixedPoint] | None = None, money_scale: int = 8, opened_at: datetime | None = None, + dividend_execution_mode: DividendExecutionMode | None = None, + fx_valuation_mode: FxValuationMode = FxValuationMode.LEGACY, + entitlement_evidence_verifier: EntitlementEvidenceVerifier | None = None, ) -> None: if not account_id.strip() or not base_currency.strip(): raise ValidationError("account_id and base_currency are required") if not 0 <= money_scale <= 18: raise ValidationError("money_scale must be in [0, 18]") + if dividend_execution_mode is not None and not isinstance( + dividend_execution_mode, DividendExecutionMode + ): + raise ValidationError("dividend_execution_mode has an invalid type") + if not isinstance(fx_valuation_mode, FxValuationMode): + raise ValidationError("fx_valuation_mode has an invalid type") + if ( + dividend_execution_mode is not None + and fx_valuation_mode is not FxValuationMode.EVIDENCED_PIT + ): + raise ValidationError("new dividend lifecycle execution requires EVIDENCED_PIT") + if fx_valuation_mode is FxValuationMode.EVIDENCED_PIT and dividend_execution_mode is None: + raise ValidationError("EVIDENCED_PIT requires an explicit dividend execution mode") + if ( + dividend_execution_mode is DividendExecutionMode.PRODUCTION_CERTIFIED + and entitlement_evidence_verifier is None + ): + raise ValidationError("production dividend execution requires a trusted verifier") + if fx_valuation_mode is FxValuationMode.EVIDENCED_PIT and fx_to_base: + raise ValidationError("EVIDENCED_PIT does not accept legacy initial FX snapshots") self._account_id = account_id self._base_currency = _currency(base_currency, "base_currency") self._instruments = MappingProxyType(dict(instruments)) @@ -114,6 +171,9 @@ def __init__( if self._is_derivative(spec) ) self._money_scale = money_scale + self._dividend_execution_mode = dividend_execution_mode + self._fx_valuation_mode = fx_valuation_mode + self._entitlement_evidence_verifier = entitlement_evidence_verifier self._initial_cash = dict(initial_cash or {}) self._initial_fx = dict(fx_to_base or {}) self._default_opened_at = ( @@ -145,6 +205,18 @@ def reset(self, *, opened_at: datetime | None = None) -> None: self._position_lots: dict[str, list[tuple[date, Decimal]]] = {} self._fill_close_allocations: dict[str, tuple[Decimal, Decimal]] = {} self._dividend_entitlements: dict[tuple[str, str, date], tuple[Decimal, Decimal]] = {} + self._dividend_lifecycle_states = {} + self._dividend_execution_records = [] + self._dividend_execution_by_key = {} + self._dividend_phase_fingerprints = {} + self._dividend_pit_fx_observations = [] + self._dividend_pit_fx_by_event_id = {} + self._dividend_pit_fx_records = [] + self._recorded_dividend_valuations = {} + self._dividend_valuation_records = [] + self._dividend_operation_log = [] + self._dividend_replay_facts: list[dict[str, object]] = [] + self._dividend_replay_sequence = 0 self._posting_cache: dict[ tuple[str, str, Decimal, str | None, Decimal | None, int], Posting ] = {} @@ -166,7 +238,7 @@ def reset(self, *, opened_at: datetime | None = None) -> None: event_time=opening_time, postings=( self._posting("assets:cash", currency, value), - self._posting("equity:opening", currency, -value), + self._posting("equity:opening", currency, value.copy_negate()), ), ) self._post(transaction) @@ -196,10 +268,23 @@ def book_external_cash( ) if self._import_seen(transaction): return self.snapshot(self._event_time) - if event_time < self._event_time or self.cash_balance(currency) + decimal(amount) < 0: + resulting_cash = add_decimal_exact(self.cash_balance(currency), decimal(amount)) + if event_time < self._event_time or resulting_cash < 0: raise ValidationError("external cash would reverse time or overdraw account") + transaction_before = len(self._transactions) self._post(transaction) self._event_time = event_time + self._record_dividend_replay_fact( + kind="external_cash", + payload={ + "transfer_id": transfer_id, + "amount": {"units": amount.units, "scale": amount.scale}, + "currency": currency, + "event_time": event_time.isoformat().replace("+00:00", "Z"), + }, + transaction_count_before=transaction_before, + transaction_count_after=len(self._transactions), + ) return self.snapshot(event_time) def book_opening_position( @@ -217,7 +302,7 @@ def book_opening_position( raise ValidationError("opening position requires positive cash-asset quantity and cost") if decimal(quantity) % decimal(spec.quantity_step) or acquired_on > self._event_time.date(): raise ValidationError("invalid opening quantity step or acquisition date") - cost = decimal(quantity) * decimal(average_cost) * decimal(spec.contract_multiplier) + cost = self._product_for_valuation(quantity, average_cost, spec.contract_multiplier) at = self._default_opened_at transaction = self._make_transaction( event_type=LedgerEventType.SETTLEMENT, @@ -231,7 +316,7 @@ def book_opening_position( cost, instrument_id=instrument_id, ), - self._posting("equity:opening", spec.settlement_currency, -cost), + self._posting("equity:opening", spec.settlement_currency, cost.copy_negate()), self._posting( "assets:position", spec.settlement_currency, @@ -245,7 +330,7 @@ def book_opening_position( spec.settlement_currency, Decimal(0), instrument_id=instrument_id, - quantity_delta=-decimal(quantity), + quantity_delta=decimal(quantity).copy_negate(), quantity_scale=quantity.scale, ), ), @@ -256,9 +341,21 @@ def book_opening_position( return self.snapshot(self._event_time) if self._event_time != at or self._positions.get(instrument_id, 0): raise ValidationError("positions may only be imported before account activity") + transaction_before = len(self._transactions) self._post(transaction) self._position_lots[instrument_id] = [(acquired_on, decimal(quantity))] self._marks[instrument_id] = (decimal(average_cost), at, transaction.reference_id) + self._record_dividend_replay_fact( + kind="opening_position", + payload={ + "instrument_id": instrument_id, + "quantity": {"units": quantity.units, "scale": quantity.scale}, + "average_cost": {"units": average_cost.units, "scale": average_cost.scale}, + "acquired_on": acquired_on.isoformat(), + }, + transaction_count_before=transaction_before, + transaction_count_after=len(self._transactions), + ) return self.snapshot(at) def _import_seen(self, transaction: LedgerTransaction) -> bool: @@ -333,6 +430,20 @@ def capture_state(self) -> dict[str, object]: "position_lots": self._position_lots, "fill_close_allocations": self._fill_close_allocations, "dividend_entitlements": self._dividend_entitlements, + "dividend_lifecycle_states": self._dividend_lifecycle_states, + "dividend_execution_records": self._dividend_execution_records, + "dividend_execution_by_key": self._dividend_execution_by_key, + "dividend_phase_fingerprints": self._dividend_phase_fingerprints, + "dividend_pit_fx_observations": self._dividend_pit_fx_observations, + "dividend_pit_fx_by_event_id": self._dividend_pit_fx_by_event_id, + "dividend_pit_fx_records": self._dividend_pit_fx_records, + "recorded_dividend_valuations": self._recorded_dividend_valuations, + "dividend_valuation_records": self._dividend_valuation_records, + "dividend_operation_log": self._dividend_operation_log, + "dividend_replay_facts": self._dividend_replay_facts, + "dividend_replay_sequence": self._dividend_replay_sequence, + "dividend_execution_mode": self._dividend_execution_mode, + "fx_valuation_mode": self._fx_valuation_mode, "posting_cache": self._posting_cache, "fx": self._fx, "fx_history": self._fx_history, @@ -363,11 +474,117 @@ def _restore_captured_state(self, state: dict[str, object]) -> None: self._position_lots = restored["position_lots"] self._fill_close_allocations = restored["fill_close_allocations"] self._dividend_entitlements = restored["dividend_entitlements"] + self._dividend_lifecycle_states = restored["dividend_lifecycle_states"] + self._dividend_execution_records = restored["dividend_execution_records"] + self._dividend_execution_by_key = restored["dividend_execution_by_key"] + self._dividend_phase_fingerprints = restored["dividend_phase_fingerprints"] + self._dividend_pit_fx_observations = restored["dividend_pit_fx_observations"] + self._dividend_pit_fx_by_event_id = restored["dividend_pit_fx_by_event_id"] + self._dividend_pit_fx_records = restored["dividend_pit_fx_records"] + self._recorded_dividend_valuations = restored["recorded_dividend_valuations"] + self._dividend_valuation_records = restored["dividend_valuation_records"] + self._dividend_operation_log = restored["dividend_operation_log"] + self._dividend_replay_facts = restored["dividend_replay_facts"] + self._dividend_replay_sequence = restored["dividend_replay_sequence"] + self._dividend_execution_mode = restored["dividend_execution_mode"] + self._fx_valuation_mode = restored["fx_valuation_mode"] self._posting_cache = restored["posting_cache"] self._fx = restored["fx"] self._fx_history = restored["fx_history"] self._event_time = restored["event_time"] + def capture_dividend_export_state(self) -> dict[str, object]: + """Seal replay state and immutable construction inputs in one captured value.""" + + return { + "state": self.capture_state(), + "account_id": self.account_id, + "base_currency": self.base_currency, + "instruments": dict(self.instruments), + "initial_cash": dict(self._initial_cash), + "money_scale": self.money_scale, + "opened_at": self._default_opened_at, + "dividend_execution_mode": self.dividend_execution_mode, + "fx_valuation_mode": self.fx_valuation_mode, + "entitlement_evidence_verifier": self._entitlement_evidence_verifier, + } + + def _next_dividend_replay_sequence(self) -> int: + sequence = self._dividend_replay_sequence + self._dividend_replay_sequence += 1 + return sequence + + def _record_dividend_replay_fact( + self, + *, + kind: str, + payload: Mapping[str, object], + transaction_count_before: int, + transaction_count_after: int, + ) -> None: + if self.dividend_execution_mode is None: + return + self._dividend_replay_facts.append( + { + "operation_sequence": self._next_dividend_replay_sequence(), + "kind": kind, + "payload": deepcopy(dict(payload)), + "transaction_count_before": transaction_count_before, + "transaction_count_after": transaction_count_after, + } + ) + + @staticmethod + def _mark_replay_payload( + event: MarketEvent, + *, + event_id: str, + price: FixedPoint, + ) -> dict[str, object]: + return { + "event_type": "mark_price", + "event_id": event_id, + "instrument_id": event.instrument_id, + "event_time": event.event_time.isoformat().replace("+00:00", "Z"), + "received_at": event.received_at.isoformat().replace("+00:00", "Z"), + "available_at": event.available_at.isoformat().replace("+00:00", "Z"), + "source": event.source, + "trading_day": event.trading_day.isoformat(), + "session_id": event.session_id, + "sequence": event.sequence, + "price": {"units": price.units, "scale": price.scale}, + } + + def _record_ledger_event_replay_fact( + self, + event: LedgerEvent, + *, + trading_day: date | None, + transaction_count_before: int, + ) -> None: + event_kind = { + Fill: "fill", + Fee: "fee", + Funding: "funding", + Settlement: "settlement", + CorporateActionEvent: "corporate_action", + }[type(event)] + payload = ( + market_event_payload(event) + if isinstance(event, CorporateActionEvent) + else execution_payload(event) + ) + self._record_dividend_replay_fact( + kind="ledger_event", + payload={ + "event_kind": event_kind, + "event": payload, + "trading_day": trading_day.isoformat() if trading_day is not None else None, + }, + transaction_count_before=transaction_count_before, + transaction_count_after=len(self._transactions), + ) + @property def transactions(self) -> tuple[LedgerTransaction, ...]: return tuple(self._transactions) @@ -384,6 +601,14 @@ def base_currency(self) -> str: def money_scale(self) -> int: return self._money_scale + @property + def dividend_execution_mode(self) -> DividendExecutionMode | None: + return self._dividend_execution_mode + + @property + def fx_valuation_mode(self) -> FxValuationMode: + return self._fx_valuation_mode + @property def instruments(self) -> Mapping[str, InstrumentSpec]: return self._instruments @@ -406,6 +631,31 @@ def journal_sha256(self) -> str: raise ValidationError( "ledger journal hash is unavailable after artifact abort; reset required" ) + if self._dividend_operation_log: + payload = { + "schema": DIVIDEND_JOURNAL_SCHEMA_ID, + "execution_mode": self.dividend_execution_mode.value, + "fx_valuation_mode": self.fx_valuation_mode.value, + "pit_fx_observations": [item.to_dict() for item in self._dividend_pit_fx_records], + "dividend_states": [ + state.to_dict() for _, state in sorted(self._dividend_lifecycle_states.items()) + ], + "dividend_records": [item.to_dict() for item in self._dividend_operation_log], + "marks": [ + { + "event_id": event_id, + "event_time": event_time.isoformat(), + "instrument_id": instrument_id, + "price": str(price), + } + for instrument_id, (price, event_time, event_id) in sorted(self._marks.items()) + ], + "transactions": [ + json.loads(self._transaction_bytes(transaction)) + for transaction in self._transactions + ], + } + return hashlib.sha256(canonical_bytes(payload)).hexdigest() digest = hashlib.sha256() digest.update(b'{"fx_snapshots":[') for index, (currency, rate, event_time) in enumerate(self._fx_history): @@ -474,6 +724,8 @@ def _transaction_bytes(transaction: LedgerTransaction) -> bytes: def set_fx_rate(self, currency: str, rate: FixedPoint, *, event_time: datetime) -> None: self._require_mutable() + if self.fx_valuation_mode is FxValuationMode.EVIDENCED_PIT: + raise ValidationError("EVIDENCED_PIT_REJECTS_LEGACY_FX") currency = _currency(currency) event_time = ensure_utc_datetime(event_time, field="event_time") value = decimal(rate) @@ -498,12 +750,24 @@ def set_fx_rate(self, currency: str, rate: FixedPoint, *, event_time: datetime) def convert_to_base( self, amount: Decimal | FixedPoint, currency: str, *, event_time: datetime ) -> Decimal: - return self._to_base( + return self._convert_for_valuation( amount, _currency(currency), ensure_utc_datetime(event_time, field="event_time"), ) + def apply_dividend_lifecycle(self, request: DividendExecutionRequest): + return apply_dividend_lifecycle_request(self, request) + + def observe_pit_fx(self, rate) -> PitFxObservationRecord: + return observe_dividend_pit_fx(self, rate) + + def dividend_exposure(self, *, as_of: datetime) -> DividendExposureSnapshot: + return build_dividend_exposure(self, as_of=as_of) + + def record_dividend_valuation(self, *, as_of: datetime) -> DividendValuationRecord: + return build_dividend_valuation(self, as_of=as_of) + def cash_balance(self, currency: str) -> Decimal: return self._accounts.get(("assets:cash", currency, None), Decimal(0)) @@ -516,31 +780,38 @@ def dividend_receivable_balance( """Return declared cash dividends that have not reached their payment date.""" currency = _currency(currency) - return sum( - ( - amount - for (account, entry_currency, receivable_key), amount in self._accounts.items() - if account == "assets:dividend_receivable" - and entry_currency == currency - and ( - instrument_id is None - or ( - receivable_key is not None - and receivable_key.startswith(f"{instrument_id}@") + dividend_keys = { + f"dividend:{state.dividend_id}" + for state in self._dividend_lifecycle_states.values() + if state.instrument_id == instrument_id + } + values = ( + amount + for (account, entry_currency, receivable_key), amount in self._accounts.items() + if account == "assets:dividend_receivable" + and entry_currency == currency + and ( + instrument_id is None + or ( + receivable_key is not None + and ( + receivable_key.startswith(f"{instrument_id}@") + or receivable_key in dividend_keys ) ) - ), - Decimal(0), + ) + ) + return ( + sum_decimal_exact(values) + if self.fx_valuation_mode is FxValuationMode.EVIDENCED_PIT + else sum(values, Decimal(0)) ) def _dividend_receivable_value(self, event_time: datetime) -> Decimal: - return sum( - ( - self._to_base(amount, currency, event_time) - for (account, currency, _), amount in self._accounts.items() - if account == "assets:dividend_receivable" - ), - Decimal(0), + return self._sum_for_valuation( + self._convert_for_valuation(amount, currency, event_time) + for (account, currency, _), amount in self._accounts.items() + if account == "assets:dividend_receivable" ) @property @@ -555,16 +826,17 @@ def risk_balances( ) -> tuple[dict[str, Decimal], Mapping[str, Decimal], Decimal, Decimal]: """Return exact decimal balances needed by the hot pre-trade risk path.""" event_time = ensure_utc_datetime(event_time, field="event_time") + self._require_valuation_time(event_time) cash = { currency: amount for (account, currency, instrument_id), amount in self._accounts.items() if account == "assets:cash" and instrument_id is None } - nav = sum( - (self._to_base(amount, currency, event_time) for currency, amount in cash.items()), - Decimal(0), + nav = self._sum_for_valuation( + self._convert_for_valuation(amount, currency, event_time) + for currency, amount in cash.items() ) - nav += self._dividend_receivable_value(event_time) + nav = add_decimal_exact(nav, self._dividend_receivable_value(event_time)) initial_margin = Decimal(0) for instrument_id, quantity in self._positions.items(): spec = self._spec(instrument_id) @@ -572,34 +844,46 @@ def risk_balances( mark = self._mark_price(instrument_id, fallback=average) multiplier = decimal(spec.contract_multiplier) if self._is_derivative(spec): - nav += self._to_base( - (mark - average) * quantity * multiplier, - spec.settlement_currency, - event_time, + nav = add_decimal_exact( + nav, + self._convert_for_valuation( + self._product_for_valuation( + self._difference_for_valuation(mark, average), quantity, multiplier + ), + spec.settlement_currency, + event_time, + ), ) - initial_margin += self._to_base( - abs(mark * quantity * multiplier) * _meta_decimal(spec, "initial_margin_rate"), - spec.settlement_currency, - event_time, + initial_margin = add_decimal_exact( + initial_margin, + self._convert_for_valuation( + self._product_for_valuation( + self._product_for_valuation(mark, quantity, multiplier).copy_abs(), + _meta_decimal(spec, "initial_margin_rate"), + ), + spec.settlement_currency, + event_time, + ), ) else: - nav += self._to_base( - mark * quantity * multiplier, - spec.settlement_currency, - event_time, + nav = add_decimal_exact( + nav, + self._convert_for_valuation( + self._product_for_valuation(mark, quantity, multiplier), + spec.settlement_currency, + event_time, + ), ) return cash, MappingProxyType(self._positions), nav, initial_margin def portfolio_risk_snapshot(self, event_time: datetime) -> PortfolioRiskSnapshot: """Build an exact, read-only base-currency exposure view at one PIT timestamp.""" at = ensure_utc_datetime(event_time, field="event_time") + self._require_valuation_time(at) account = self.snapshot(at) - cash_value = sum( - ( - self._to_base(decimal(amount), currency, at) - for currency, amount in account.cash_balances.items() - ), - Decimal(0), + cash_value = self._sum_for_valuation( + self._convert_for_valuation(decimal(amount), currency, at) + for currency, amount in account.cash_balances.items() ) positions: list[PositionRiskSnapshot] = [] gross_exposure = Decimal(0) @@ -613,8 +897,10 @@ def portfolio_risk_snapshot(self, event_time: datetime) -> PortfolioRiskSnapshot spec = self._spec(instrument_id) mark = self._mark_price(instrument_id) multiplier = decimal(spec.contract_multiplier) - local_notional = mark * quantity * multiplier - base_notional = self._to_base(local_notional, spec.settlement_currency, at) + local_notional = self._product_for_valuation(mark, quantity, multiplier) + base_notional = self._convert_for_valuation( + local_notional, spec.settlement_currency, at + ) position_initial = Decimal(0) position_maintenance = Decimal(0) if self._is_derivative(spec): @@ -624,20 +910,24 @@ def portfolio_risk_snapshot(self, event_time: datetime) -> PortfolioRiskSnapshot f"InstrumentSpec metadata {key!r} is required for risk snapshot" ) absolute_notional = abs(local_notional) - position_initial = self._to_base( - absolute_notional * _meta_decimal(spec, "initial_margin_rate"), + position_initial = self._convert_for_valuation( + self._product_for_valuation( + absolute_notional, _meta_decimal(spec, "initial_margin_rate") + ), spec.settlement_currency, at, ) - position_maintenance = self._to_base( - absolute_notional * _meta_decimal(spec, "maintenance_margin_rate"), + position_maintenance = self._convert_for_valuation( + self._product_for_valuation( + absolute_notional, _meta_decimal(spec, "maintenance_margin_rate") + ), spec.settlement_currency, at, ) - gross_exposure += abs(base_notional) - net_exposure += base_notional - initial_margin += position_initial - maintenance_margin += position_maintenance + gross_exposure = add_decimal_exact(gross_exposure, base_notional.copy_abs()) + net_exposure = add_decimal_exact(net_exposure, base_notional) + initial_margin = add_decimal_exact(initial_margin, position_initial) + maintenance_margin = add_decimal_exact(maintenance_margin, position_maintenance) positions.append( PositionRiskSnapshot( instrument_id=instrument_id, @@ -645,10 +935,12 @@ def portfolio_risk_snapshot(self, event_time: datetime) -> PortfolioRiskSnapshot venue=spec.venue, settlement_currency=spec.settlement_currency, quantity=quantity_fp, - mark_price=fixed(mark, spec.price_tick.scale), - base_notional=fixed(base_notional, self.money_scale), - initial_margin=fixed(position_initial, self.money_scale), - maintenance_margin=fixed(position_maintenance, self.money_scale), + mark_price=self._fixed_for_valuation(mark, spec.price_tick.scale), + base_notional=self._fixed_for_valuation(base_notional, self.money_scale), + initial_margin=self._fixed_for_valuation(position_initial, self.money_scale), + maintenance_margin=self._fixed_for_valuation( + position_maintenance, self.money_scale + ), ) ) return PortfolioRiskSnapshot( @@ -656,11 +948,11 @@ def portfolio_risk_snapshot(self, event_time: datetime) -> PortfolioRiskSnapshot event_time=at, base_currency=account.base_currency, nav=account.nav, - cash_value=fixed(cash_value, self.money_scale), - gross_exposure=fixed(gross_exposure, self.money_scale), - net_exposure=fixed(net_exposure, self.money_scale), - initial_margin=fixed(initial_margin, self.money_scale), - maintenance_margin=fixed(maintenance_margin, self.money_scale), + cash_value=self._fixed_for_valuation(cash_value, self.money_scale), + gross_exposure=self._fixed_for_valuation(gross_exposure, self.money_scale), + net_exposure=self._fixed_for_valuation(net_exposure, self.money_scale), + initial_margin=self._fixed_for_valuation(initial_margin, self.money_scale), + maintenance_margin=self._fixed_for_valuation(maintenance_margin, self.money_scale), positions=tuple(positions), ) @@ -682,6 +974,7 @@ def mark( raise ValidationError("ledger event time moved backwards") prior_mark = self._marks.get(event.instrument_id) prior_time = self._event_time + transaction_before = len(self._transactions) try: self._marks[event.instrument_id] = ( decimal(event.price), @@ -690,7 +983,18 @@ def mark( ) self._mark_fingerprints[event.event_id] = event self._event_time = max(self._event_time, event.available_at) - return self.snapshot(event.available_at) if create_snapshot else None + result = self.snapshot(event.available_at) if create_snapshot else None + self._record_dividend_replay_fact( + kind="mark", + payload=self._mark_replay_payload( + event, + event_id=event.event_id, + price=event.price, + ), + transaction_count_before=transaction_before, + transaction_count_after=len(self._transactions), + ) + return result except Exception: if prior_mark is None: self._marks.pop(event.instrument_id, None) @@ -737,12 +1041,24 @@ def observe_market( raise ValidationError("ledger event time moved backwards") prior_mark = self._marks.get(event.instrument_id) prior_time = self._event_time + transaction_before = len(self._transactions) try: self._marks[event.instrument_id] = (decimal(price), event.available_at, synthetic_id) if not trusted_unique: self._mark_fingerprints[synthetic_id] = event self._event_time = max(self._event_time, event.available_at) - return self.snapshot(event.available_at) if create_snapshot else None + result = self.snapshot(event.available_at) if create_snapshot else None + self._record_dividend_replay_fact( + kind="mark", + payload=self._mark_replay_payload( + event, + event_id=synthetic_id, + price=price, + ), + transaction_count_before=transaction_before, + transaction_count_after=len(self._transactions), + ) + return result except Exception: if prior_mark is None: self._marks.pop(event.instrument_id, None) @@ -760,20 +1076,19 @@ def liquidation_required(self, event_time: datetime | None = None) -> bool: if event_time is not None else self._event_time ) - if not any( + self._require_valuation_time(at) + has_derivative = any( quantity and instrument_id in self._derivative_instruments for instrument_id, quantity in self._positions.items() - ): + ) + if not has_derivative and self.fx_valuation_mode is FxValuationMode.LEGACY: return False - nav = sum( - ( - self._to_base(amount, currency, at) - for (account, currency, instrument_id), amount in self._accounts.items() - if account == "assets:cash" and instrument_id is None - ), - Decimal(0), + nav = self._sum_for_valuation( + self._convert_for_valuation(amount, currency, at) + for (account, currency, instrument_id), amount in self._accounts.items() + if account == "assets:cash" and instrument_id is None ) - nav += self._dividend_receivable_value(at) + nav = add_decimal_exact(nav, self._dividend_receivable_value(at)) maintenance_margin = Decimal(0) for instrument_id, quantity in self._positions.items(): spec = self._spec(instrument_id) @@ -781,25 +1096,40 @@ def liquidation_required(self, event_time: datetime | None = None) -> bool: mark = self._mark_price(instrument_id, fallback=average) multiplier = decimal(spec.contract_multiplier) if self._is_derivative(spec): - nav += self._to_base( - (mark - average) * quantity * multiplier, - spec.settlement_currency, - at, + nav = add_decimal_exact( + nav, + self._convert_for_valuation( + self._product_for_valuation( + self._difference_for_valuation(mark, average), + quantity, + multiplier, + ), + spec.settlement_currency, + at, + ), ) - maintenance_margin += self._to_base( - abs(mark * quantity * multiplier) - * _meta_decimal(spec, "maintenance_margin_rate"), - spec.settlement_currency, - at, + maintenance_margin = add_decimal_exact( + maintenance_margin, + self._convert_for_valuation( + self._product_for_valuation( + self._product_for_valuation(mark, quantity, multiplier).copy_abs(), + _meta_decimal(spec, "maintenance_margin_rate"), + ), + spec.settlement_currency, + at, + ), ) else: - nav += self._to_base( - mark * quantity * multiplier, - spec.settlement_currency, - at, + nav = add_decimal_exact( + nav, + self._convert_for_valuation( + self._product_for_valuation(mark, quantity, multiplier), + spec.settlement_currency, + at, + ), ) - rounded_nav = fixed(nav, self.money_scale) - rounded_maintenance = fixed(maintenance_margin, self.money_scale) + rounded_nav = self._fixed_for_valuation(nav, self.money_scale) + rounded_maintenance = self._fixed_for_valuation(maintenance_margin, self.money_scale) return rounded_maintenance.units > 0 and rounded_nav.units <= rounded_maintenance.units def apply_corporate_action(self, action, *, at): @@ -810,11 +1140,21 @@ def apply_corporate_action(self, action, *, at): def apply(self, event: LedgerEvent, *, create_snapshot: bool = True) -> AccountSnapshot | None: trading_day = event.event_time.date() if isinstance(event, Fill) else None - return self._apply( + reference_id = self._event_identity(event) + is_new = reference_id not in self._event_fingerprints + transaction_before = len(self._transactions) + result = self._apply( event, trading_day=trading_day, create_snapshot=create_snapshot, ) + if is_new: + self._record_ledger_event_replay_fact( + event, + trading_day=trading_day, + transaction_count_before=transaction_before, + ) + return result def _apply( self, @@ -1040,11 +1380,22 @@ def apply_with_trading_day( ) -> AccountSnapshot | None: if not isinstance(trading_day, date) or isinstance(trading_day, datetime): raise ValidationError("trading_day must be a date") - return self._apply( + applied_trading_day = trading_day if isinstance(event, Fill) else None + reference_id = self._event_identity(event) + is_new = reference_id not in self._event_fingerprints + transaction_before = len(self._transactions) + result = self._apply( event, - trading_day=trading_day if isinstance(event, Fill) else None, + trading_day=applied_trading_day, create_snapshot=create_snapshot, ) + if is_new: + self._record_ledger_event_replay_fact( + event, + trading_day=applied_trading_day, + transaction_count_before=transaction_before, + ) + return result def _apply_replay_event( self, @@ -1059,6 +1410,7 @@ def _apply_replay_event( raise ValidationError("fill replay application requires a trading_day date") elif trading_day is not None: raise ValidationError("trading_day is only valid for fill replay application") + transaction_before = len(self._transactions) self._apply( event, trading_day=trading_day, @@ -1066,6 +1418,11 @@ def _apply_replay_event( local_rollback=False, trusted_unique=True, ) + self._record_ledger_event_replay_fact( + event, + trading_day=trading_day, + transaction_count_before=transaction_before, + ) def _validate_event(self, event: LedgerEvent) -> None: if isinstance(event, CorporateActionEvent): @@ -1140,55 +1497,87 @@ def snapshot(self, event_time: datetime | None = None) -> AccountSnapshot: if event_time is not None else self._event_time ) + self._require_valuation_time(at) cash: dict[str, FixedPoint] = {} for (account, currency, instrument_id), amount in self._accounts.items(): if account == "assets:cash" and instrument_id is None: cash[currency] = fixed( - decimal(cash.get(currency, FixedPoint(0, self.money_scale))) + amount, + add_decimal_exact( + decimal(cash.get(currency, FixedPoint(0, self.money_scale))), + amount, + ), self.money_scale, + rounding=( + None + if self.fx_valuation_mode is FxValuationMode.EVIDENCED_PIT + else ROUND_HALF_EVEN + ), ) positions: dict[str, FixedPoint] = {} costs: dict[str, FixedPoint] = {} realized: dict[str, FixedPoint] = {} unrealized: dict[str, FixedPoint] = {} - nav = sum( - (self._to_base(value, currency, at) for currency, value in cash.items()), Decimal(0) + nav = self._sum_for_valuation( + self._convert_for_valuation(value, currency, at) for currency, value in cash.items() ) - nav += self._dividend_receivable_value(at) + nav = add_decimal_exact(nav, self._dividend_receivable_value(at)) initial_margin = Decimal(0) maintenance_margin = Decimal(0) for instrument_id, quantity in sorted(self._positions.items()): spec = self._spec(instrument_id) - positions[instrument_id] = fixed(quantity, spec.quantity_step.scale) + positions[instrument_id] = self._fixed_for_valuation(quantity, spec.quantity_step.scale) average = self._average_cost(instrument_id) - costs[instrument_id] = fixed(average, spec.price_tick.scale) + costs[instrument_id] = self._fixed_for_valuation(average, spec.price_tick.scale) realized_value = self._realized(instrument_id) - realized[instrument_id] = fixed( - self._to_base(realized_value, spec.settlement_currency, at), self.money_scale + realized[instrument_id] = self._fixed_for_valuation( + self._convert_for_valuation(realized_value, spec.settlement_currency, at), + self.money_scale, ) mark = self._mark_price(instrument_id, fallback=average) multiplier = decimal(spec.contract_multiplier) - pnl = (mark - average) * quantity * multiplier - unrealized[instrument_id] = fixed( - self._to_base(pnl, spec.settlement_currency, at), self.money_scale + pnl = self._product_for_valuation( + self._difference_for_valuation(mark, average), quantity, multiplier + ) + unrealized[instrument_id] = self._fixed_for_valuation( + self._convert_for_valuation(pnl, spec.settlement_currency, at), + self.money_scale, ) if self._is_derivative(spec): - nav += self._to_base(pnl, spec.settlement_currency, at) - notional = abs(mark * quantity * multiplier) - initial_margin += self._to_base( - notional * _meta_decimal(spec, "initial_margin_rate"), - spec.settlement_currency, - at, + nav = add_decimal_exact( + nav, self._convert_for_valuation(pnl, spec.settlement_currency, at) ) - maintenance_margin += self._to_base( - notional * _meta_decimal(spec, "maintenance_margin_rate"), - spec.settlement_currency, - at, + notional = self._product_for_valuation(mark, quantity, multiplier).copy_abs() + initial_margin = add_decimal_exact( + initial_margin, + self._convert_for_valuation( + self._product_for_valuation( + notional, _meta_decimal(spec, "initial_margin_rate") + ), + spec.settlement_currency, + at, + ), + ) + maintenance_margin = add_decimal_exact( + maintenance_margin, + self._convert_for_valuation( + self._product_for_valuation( + notional, _meta_decimal(spec, "maintenance_margin_rate") + ), + spec.settlement_currency, + at, + ), ) else: - nav += self._to_base(mark * quantity * multiplier, spec.settlement_currency, at) - nav_value = fixed(nav, self.money_scale) - maintenance = fixed(maintenance_margin, self.money_scale) + nav = add_decimal_exact( + nav, + self._convert_for_valuation( + self._product_for_valuation(mark, quantity, multiplier), + spec.settlement_currency, + at, + ), + ) + nav_value = self._fixed_for_valuation(nav, self.money_scale) + maintenance = self._fixed_for_valuation(maintenance_margin, self.money_scale) snapshot = AccountSnapshot( account_id=self.account_id, event_time=at, @@ -1199,7 +1588,7 @@ def snapshot(self, event_time: datetime | None = None) -> AccountSnapshot: cost_basis=costs, realized_pnl=realized, unrealized_pnl=unrealized, - initial_margin=fixed(initial_margin, self.money_scale), + initial_margin=self._fixed_for_valuation(initial_margin, self.money_scale), maintenance_margin=maintenance, liquidation_required=maintenance.units > 0 and nav_value.units <= maintenance.units, ) @@ -1209,21 +1598,33 @@ def snapshot(self, event_time: datetime | None = None) -> AccountSnapshot: def assert_nav_residual(self, snapshot: AccountSnapshot) -> None: expected = Decimal(0) at = snapshot.event_time + self._require_valuation_time(at) for currency, balance in snapshot.cash_balances.items(): - expected += self._to_base(decimal(balance), currency, at) - expected += self._dividend_receivable_value(at) + expected = add_decimal_exact( + expected, self._convert_for_valuation(decimal(balance), currency, at) + ) + expected = add_decimal_exact(expected, self._dividend_receivable_value(at)) for instrument_id, quantity_fp in snapshot.positions.items(): spec = self._spec(instrument_id) quantity = decimal(quantity_fp) mark = self._mark_price(instrument_id, fallback=self._average_cost(instrument_id)) multiplier = decimal(spec.contract_multiplier) if self._is_derivative(spec): - component = (mark - self._average_cost(instrument_id)) * quantity * multiplier + component = self._product_for_valuation( + self._difference_for_valuation(mark, self._average_cost(instrument_id)), + quantity, + multiplier, + ) else: - component = mark * quantity * multiplier - expected += self._to_base(component, spec.settlement_currency, at) - residual = abs(decimal(snapshot.nav) - expected) - tolerance = max(abs(decimal(snapshot.nav)) * Decimal("1e-8"), Decimal("0.01")) + component = self._product_for_valuation(mark, quantity, multiplier) + expected = add_decimal_exact( + expected, self._convert_for_valuation(component, spec.settlement_currency, at) + ) + residual = self._difference_for_valuation(decimal(snapshot.nav), expected).copy_abs() + tolerance = max( + self._product_for_valuation(decimal(snapshot.nav).copy_abs(), Decimal("1e-8")), + Decimal("0.01"), + ) if residual > tolerance: raise ValidationError(f"NAV residual {residual} exceeds tolerance {tolerance}") @@ -1942,6 +2343,12 @@ def _average_cost(self, instrument_id: str) -> Decimal: return Decimal(0) spec = self._spec(instrument_id) cost = self._position_cost(instrument_id, derivative=self._is_derivative(spec)) + if self.fx_valuation_mode is FxValuationMode.EVIDENCED_PIT: + return fraction_decimal_exact( + decimal_fraction(cost.copy_abs()) + / decimal_fraction(quantity.copy_abs()) + / decimal_fraction(spec.contract_multiplier) + ) return abs(cost) / (abs(quantity) * decimal(spec.contract_multiplier)) def _realized(self, instrument_id: str) -> Decimal: @@ -1949,7 +2356,11 @@ def _realized(self, instrument_id: str) -> Decimal: credit = self._accounts.get( ("income:realized_pnl", spec.settlement_currency, instrument_id), Decimal(0) ) - return -credit + return ( + credit.copy_negate() + if self.fx_valuation_mode is FxValuationMode.EVIDENCED_PIT + else -credit + ) def _mark_price(self, instrument_id: str, *, fallback: Decimal | None = None) -> Decimal: mark = self._marks.get(instrument_id) @@ -1984,3 +2395,45 @@ def _to_base(self, amount: Decimal | FixedPoint, currency: str, at: datetime) -> raise ValidationError("FX snapshot was not available at valuation time") rate, available_at = historical return value * rate + + def _convert_for_valuation( + self, + amount: Decimal | FixedPoint, + currency: str, + at: datetime, + ) -> Decimal: + if self.fx_valuation_mode is FxValuationMode.EVIDENCED_PIT: + self._require_valuation_time(at) + return convert_dividend_value(self, amount, currency, at) + return self._to_base(amount, currency, at) + + def _require_valuation_time(self, at: datetime) -> None: + if self.fx_valuation_mode is FxValuationMode.EVIDENCED_PIT and at < self._event_time: + raise ValidationError("HISTORICAL_LEDGER_STATE_UNAVAILABLE") + + def _sum_for_valuation(self, values) -> Decimal: + if self.fx_valuation_mode is FxValuationMode.EVIDENCED_PIT: + return sum_decimal_exact(values) + return sum(values, Decimal(0)) + + def _product_for_valuation(self, *values: Decimal | FixedPoint | int) -> Decimal: + if self.fx_valuation_mode is FxValuationMode.EVIDENCED_PIT: + return multiply_decimal_exact(*values) + result = Decimal(1) + for value in values: + result *= decimal(value) if isinstance(value, FixedPoint) else value + return result + + def _difference_for_valuation(self, left: Decimal, right: Decimal) -> Decimal: + if self.fx_valuation_mode is FxValuationMode.EVIDENCED_PIT: + return add_decimal_exact(left, right.copy_negate()) + return left - right + + def _fixed_for_valuation(self, value: Decimal, scale: int) -> FixedPoint: + return fixed( + value, + scale, + rounding=( + None if self.fx_valuation_mode is FxValuationMode.EVIDENCED_PIT else ROUND_HALF_EVEN + ), + ) diff --git a/src/quant_execution/schemas.py b/src/quant_execution/schemas.py index c2260a3..7cce24e 100644 --- a/src/quant_execution/schemas.py +++ b/src/quant_execution/schemas.py @@ -26,6 +26,7 @@ RunResult, Settlement, ) +from quant_execution.dividends import DIVIDEND_RECORD_SCHEMA_ID, DividendRecord LEGACY_SCHEMA_VERSION = "1.0.0" SCHEMA_VERSION = "1.1.0" @@ -205,6 +206,13 @@ ), }, } +_ARROW_SCHEMAS[DIVIDEND_RECORD_SCHEMA_ID] = pa.schema( + [ + pa.field("schema", pa.string(), nullable=False), + pa.field("record_kind", pa.string(), nullable=False), + pa.field("payload", pa.large_string(), nullable=False), + ] +) _FIXED_POINT_JSON = { "type": "object", @@ -488,6 +496,113 @@ def _object_schema(properties: dict[str, Any], required: list[str]) -> dict[str, _LEGACY_JSON_SCHEMAS = deepcopy(_JSON_SCHEMAS) del _LEGACY_JSON_SCHEMAS[SETTLEMENT_SCHEMA_ID]["properties"]["settlement_price"] +_JSON_SCHEMAS[DIVIDEND_RECORD_SCHEMA_ID] = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + _object_schema( + { + "schema": {"const": DIVIDEND_RECORD_SCHEMA_ID}, + "record_kind": {"const": "phase_application"}, + "account_id": _TEXT, + "dividend_id": _TEXT, + "instrument_id": _TEXT, + "phase": {"enum": ["entitlement", "issuer_conversion", "payment"]}, + "phase_event_id": _TEXT, + "cutoff": _UTC_JSON, + "economic_effective_at": _UTC_JSON, + "available_at": _UTC_JSON, + "applied_at": _UTC_JSON, + "lifecycle_snapshot": {"type": "object"}, + "lifecycle_snapshot_sha256": _SHA256, + "phase_fingerprint": _SHA256, + "parent_state_sha256": _SHA256, + "resulting_state_sha256": _SHA256, + "execution_mode": {"enum": ["scenario_only", "production_certified"]}, + "fx_valuation_mode": {"const": "evidenced_pit"}, + "entitlement_basis": {"type": ["object", "null"]}, + "issuer_conversion_audit": {"type": ["object", "null"]}, + "transaction_ids": {"type": "array", "items": _TEXT}, + "operation_sequence": {"type": "integer", "minimum": 0}, + "transaction_count_before": {"type": "integer", "minimum": 0}, + "transaction_count_after": {"type": "integer", "minimum": 0}, + }, + [ + "schema", + "record_kind", + "account_id", + "dividend_id", + "instrument_id", + "phase", + "phase_event_id", + "cutoff", + "economic_effective_at", + "available_at", + "applied_at", + "lifecycle_snapshot", + "lifecycle_snapshot_sha256", + "phase_fingerprint", + "parent_state_sha256", + "resulting_state_sha256", + "execution_mode", + "fx_valuation_mode", + "entitlement_basis", + "issuer_conversion_audit", + "transaction_ids", + "operation_sequence", + "transaction_count_before", + "transaction_count_after", + ], + ), + _object_schema( + { + "schema": {"const": DIVIDEND_RECORD_SCHEMA_ID}, + "record_kind": {"const": "pit_fx_observation"}, + "observation_sequence": {"type": "integer", "minimum": 0}, + "rate_payload": {"type": "object"}, + "rate_fingerprint": _SHA256, + "operation_sequence": {"type": "integer", "minimum": 0}, + "transaction_count": {"type": "integer", "minimum": 0}, + }, + [ + "schema", + "record_kind", + "observation_sequence", + "rate_payload", + "rate_fingerprint", + "operation_sequence", + "transaction_count", + ], + ), + _object_schema( + { + "schema": {"const": DIVIDEND_RECORD_SCHEMA_ID}, + "record_kind": {"const": "valuation"}, + "valuation_idempotency_key": _TEXT, + "as_of": _UTC_JSON, + "execution_mode": {"enum": ["scenario_only", "production_certified"]}, + "fx_valuation_mode": {"const": "evidenced_pit"}, + "selected_rates": {"type": "array", "items": {"type": "object"}}, + "result_payload": {"type": "object"}, + "result_sha256": _SHA256, + "operation_sequence": {"type": "integer", "minimum": 0}, + "transaction_count": {"type": "integer", "minimum": 0}, + }, + [ + "schema", + "record_kind", + "valuation_idempotency_key", + "as_of", + "execution_mode", + "fx_valuation_mode", + "selected_rates", + "result_payload", + "result_sha256", + "operation_sequence", + "transaction_count", + ], + ), + ], +} _JSON_SCHEMAS_BY_VERSION: dict[str, dict[str, dict[str, Any]]] = { LEGACY_SCHEMA_VERSION: _LEGACY_JSON_SCHEMAS, SCHEMA_VERSION: _JSON_SCHEMAS, @@ -536,6 +651,10 @@ def _fixed_map(values: Mapping[str, FixedPoint]) -> dict[str, dict[str, int]]: def execution_payload(value: object, *, version: str = SCHEMA_VERSION) -> dict[str, Any]: if version not in SUPPORTED_SCHEMA_VERSIONS: raise ValidationError(f"Unsupported execution schema version: {version}") + if isinstance(value, DividendRecord): + if version == LEGACY_SCHEMA_VERSION: + raise ValidationError("dividend records require execution schema version 1.1.0") + return value.to_dict() if isinstance(value, OrderIntent): return _intent_payload(value) if isinstance(value, Order): diff --git a/tests/golden/v1_1/dividend_record.json b/tests/golden/v1_1/dividend_record.json new file mode 100644 index 0000000..054457e --- /dev/null +++ b/tests/golden/v1_1/dividend_record.json @@ -0,0 +1,24 @@ +{ + "record": { + "observation_sequence": 0, + "operation_sequence": 0, + "rate_fingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "rate_payload": { + "available_at": "2026-01-02T00:00:00Z", + "base_currency": "USD", + "captured_at": "2026-01-03T00:00:00Z", + "event_id": "fx-1", + "evidence_id": "fx-evidence-1", + "observed_at": "2026-01-02T00:00:00Z", + "quote_currency": "HKD", + "rate_convention": "quote_per_base", + "rate_text": "7.8", + "schema": "puresaber.pit-fx-rate/1", + "source": "fixture" + }, + "record_kind": "pit_fx_observation", + "schema": "puresaber.execution.dividend-record/1", + "transaction_count": 2 + }, + "schema_version": "1.1.0" +} diff --git a/tests/test_dividend_artifacts.py b/tests/test_dividend_artifacts.py new file mode 100644 index 0000000..14c4ce1 --- /dev/null +++ b/tests/test_dividend_artifacts.py @@ -0,0 +1,632 @@ +from __future__ import annotations + +import json +from copy import deepcopy +from dataclasses import replace +from datetime import date, datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace + +import pytest +from conftest import fp +from quant_data_kit import CorporateActionEvent, MarkPriceEvent, market_event_payload +from quant_data_kit.exceptions import ValidationError +from test_dividend_lifecycle import ( + EX_AT, + apply_entitlement, + basis, + certified_policy, + election, + entitlement, + ledger, + lifecycle, + payment, + pit_rate, + request, + same_currency_prefix, +) + +import quant_execution.artifacts as artifacts_module +from quant_execution.artifacts import ( + ArrowReplayArtifactSink, + export_dividend_run, + load_stored_artifacts, + replay_dividend_run, +) +from quant_execution.contracts import Fee, Fill, Funding, LiquidityRole, Settlement, Side +from quant_execution.dividends import DividendExecutionMode, DividendExecutionPhase +from quant_execution.schemas import execution_payload + +UTC = timezone.utc + + +def completed_ledger(): + account = ledger() + prefix = same_currency_prefix(account) + account.apply_dividend_lifecycle( + request( + replace(prefix, payment=payment(gross="3", net="3")), + DividendExecutionPhase.PAYMENT, + cutoff=EX_AT + timedelta(days=2), + ) + ) + return account + + +def test_manifest_1_1_replays_initial_conditions_transactions_phases_and_journal( + tmp_path: Path, +) -> None: + account = completed_ledger() + before = account.capture_state() + root = tmp_path / "dividend-run" + + stored = export_dividend_run(account, root) + replayed = replay_dividend_run(root) + + assert stored.schema_version == "1.1.0" + assert set(stored.counts) == { + "orders", + "order_events", + "fills", + "fees", + "settlements", + "ledger_transactions", + "risk_events", + "dividend_records", + } + assert stored.counts["dividend_records"] == 3 + replayed_state = replayed.ledger.capture_state() + before.pop("posting_cache") + replayed_state.pop("posting_cache") + assert replayed_state == before + assert replayed.ledger.journal_sha256 == account.journal_sha256 + assert replayed.ledger.snapshot().nav == account.snapshot().nav + assert stored.run_metadata["market_admission_certified"] is False + + +def test_full_pit_fx_observation_order_and_explicit_valuation_replay(tmp_path: Path) -> None: + account = ledger() + initial = lifecycle(terms=entitlement(declared="USD", options=("USD",))) + apply_entitlement(account, initial) + first_at = EX_AT - timedelta(minutes=2) + second_at = EX_AT - timedelta(minutes=1) + first = account.observe_pit_fx(pit_rate("first", base="USD", rate="7.7", observed_at=first_at)) + second = account.observe_pit_fx( + pit_rate("second", base="USD", rate="7.8", observed_at=second_at) + ) + valuation = account.record_dividend_valuation(as_of=EX_AT) + + stored = export_dividend_run(account, tmp_path / "fx-run") + replayed = replay_dividend_run(stored).ledger + + assert [item.rate_payload for item in replayed._dividend_pit_fx_records] == [ + first.rate_payload, + second.rate_payload, + ] + assert replayed._recorded_dividend_valuations[valuation.valuation_idempotency_key] == valuation + assert replayed.snapshot(EX_AT).nav == account.snapshot(EX_AT).nav + + +def test_production_export_marks_certification_and_replay_requires_verifier( + tmp_path: Path, +) -> None: + class Verifier: + def verify_entitlement_basis(self, *, basis, lifecycle) -> bool: + return basis.certification_ref == "certified" and bool(lifecycle.dividend_id) + + verifier = Verifier() + account = ledger(mode=DividendExecutionMode.PRODUCTION_CERTIFIED, verifier=verifier) + apply_entitlement(account, evidence_basis=basis(certification_ref="certified")) + stored = export_dividend_run(account, tmp_path / "production") + + assert stored.run_metadata["market_admission_certified"] is False + assert stored.run_metadata["trusted_verification_scope"] == ["entitlement_basis"] + with pytest.raises(ValidationError, match="trusted verifier"): + replay_dividend_run(stored) + assert ( + replay_dividend_run(stored, entitlement_evidence_verifier=verifier).ledger.journal_sha256 + == account.journal_sha256 + ) + + +def test_production_manifest_reports_only_independently_verified_fact_scope( + tmp_path: Path, +) -> None: + class FullVerifier: + def verify_entitlement_basis(self, *, basis, lifecycle) -> bool: + return basis.certification_ref == "certified" + + def verify_payment_policy(self, *, policy, lifecycle) -> bool: + return policy.policy_id == "policy-1" + + def verify_dividend_payment(self, *, payment, lifecycle) -> bool: + return payment.account_id == "account" + + verifier = FullVerifier() + account = ledger(mode=DividendExecutionMode.PRODUCTION_CERTIFIED, verifier=verifier) + initial = lifecycle() + apply_entitlement( + account, + initial, + evidence_basis=basis(certification_ref="certified"), + ) + prefix = replace( + initial, + election=election("HKD"), + payment_policy=certified_policy(), + ) + account.apply_dividend_lifecycle( + request( + prefix, + DividendExecutionPhase.ISSUER_CONVERSION, + cutoff=EX_AT + timedelta(days=1), + ) + ) + account.apply_dividend_lifecycle( + request( + replace(prefix, payment=payment(gross="3", net="3")), + DividendExecutionPhase.PAYMENT, + cutoff=EX_AT + timedelta(days=2), + ) + ) + + stored = export_dividend_run(account, tmp_path / "production-full") + + assert stored.run_metadata["market_admission_certified"] is False + assert stored.run_metadata["trusted_verification_scope"] == [ + "entitlement_basis", + "payment_policy", + "actual_dividend_payment", + ] + assert ( + replay_dividend_run(stored, entitlement_evidence_verifier=verifier).ledger.journal_sha256 + == account.journal_sha256 + ) + + +def test_replay_preserves_ordered_marks_fill_valuations_lots_and_payment( + tmp_path: Path, +) -> None: + account = ledger() + prefix = same_currency_prefix(account) + mark_1_at = EX_AT + timedelta(days=1, hours=1) + mark_2_at = EX_AT + timedelta(days=1, hours=3) + account.mark( + MarkPriceEvent( + event_id="mark-1", + instrument_id=prefix.instrument_id, + event_time=mark_1_at, + received_at=mark_1_at, + available_at=mark_1_at, + source="test-mark-source", + trading_day=date(2026, 1, 3), + session_id="session-1", + sequence=1, + price=fp("11"), + ), + create_snapshot=False, + ) + valuation_1 = account.record_dividend_valuation(as_of=mark_1_at) + account.apply( + Fill( + fill_id="sell-1", + order_id="order-sell-1", + account_id="account", + strategy_id="strategy", + instrument_id=prefix.instrument_id, + side=Side.SELL, + quantity=fp("1", 0), + price=fp("12"), + event_time=EX_AT + timedelta(days=1, hours=2), + liquidity_role=LiquidityRole.TAKER, + ), + create_snapshot=False, + ) + account.apply( + Fee( + fee_id="fee-sell-1", + fill_id="sell-1", + account_id="account", + amount=fp("0.01"), + currency="HKD", + event_time=EX_AT + timedelta(days=1, hours=2, minutes=1), + fee_type="commission", + ), + create_snapshot=False, + ) + account.mark( + MarkPriceEvent( + event_id="mark-2", + instrument_id=prefix.instrument_id, + event_time=mark_2_at, + received_at=mark_2_at, + available_at=mark_2_at, + source="test-mark-source", + trading_day=date(2026, 1, 3), + session_id="session-1", + sequence=2, + price=fp("13"), + ), + create_snapshot=False, + ) + valuation_2 = account.record_dividend_valuation(as_of=mark_2_at) + account.apply_dividend_lifecycle( + request( + replace(prefix, payment=payment(gross="3", net="3")), + DividendExecutionPhase.PAYMENT, + cutoff=EX_AT + timedelta(days=2), + ) + ) + + stored = export_dividend_run(account, tmp_path / "ordered-business-facts") + replayed = replay_dividend_run(stored).ledger + + assert ( + replayed._recorded_dividend_valuations[valuation_1.valuation_idempotency_key] == valuation_1 + ) + assert ( + replayed._recorded_dividend_valuations[valuation_2.valuation_idempotency_key] == valuation_2 + ) + assert replayed._position_lots == account._position_lots + assert replayed._marks == account._marks + assert replayed._dividend_lifecycle_states == account._dividend_lifecycle_states + assert replayed.transactions == account.transactions + assert replayed.journal_sha256 == account.journal_sha256 + expected_state = account.capture_state() + actual_state = replayed.capture_state() + expected_state.pop("posting_cache") + actual_state.pop("posting_cache") + assert actual_state == expected_state + + +def test_export_reads_only_the_sealed_snapshot_after_capture( + tmp_path: Path, + monkeypatch, +) -> None: + account = completed_ledger() + expected_cash = account.cash_balance("HKD") + original_capture = account.capture_dividend_export_state + + def capture_then_mutate(): + captured = original_capture() + account.book_external_cash( + transfer_id="after-seal", + amount=fp("1"), + currency="HKD", + event_time=EX_AT + timedelta(days=3), + ) + return captured + + monkeypatch.setattr(account, "capture_dividend_export_state", capture_then_mutate) + stored = export_dividend_run(account, tmp_path / "sealed-snapshot") + replayed = replay_dividend_run(stored).ledger + + assert account.cash_balance("HKD") == expected_cash + fp("1").to_decimal() + assert replayed.cash_balance("HKD") == expected_cash + + +def test_dividend_replay_payload_parsers_cover_all_supported_event_shapes() -> None: + at = EX_AT + timedelta(days=5) + fee = Fee( + fee_id="fee", + fill_id="fill", + account_id="account", + amount=fp("0.01"), + currency="HKD", + event_time=at, + fee_type="commission", + ) + funding = Funding( + funding_id="funding", + account_id="account", + instrument_id="HK:DIVIDEND", + amount=fp("0.10"), + currency="HKD", + event_time=at, + ) + settlement = Settlement( + settlement_id="settlement", + account_id="account", + instrument_id="HK:DIVIDEND", + amount=fp("0.20"), + currency="HKD", + event_time=at, + settlement_type="cash_adjustment", + settlement_price=fp("12"), + ) + action = CorporateActionEvent( + event_id="action", + instrument_id="HK:DIVIDEND", + event_time=at, + received_at=at, + available_at=at, + source="issuer", + trading_day=at.date(), + session_id="session", + sequence=1, + action_type="split", + effective_date=at.date(), + ratio=fp("2"), + ) + cash_action = replace( + action, + event_id="cash-action", + action_type="cash_dividend_entitlement", + ratio=None, + cash_amount=fp("1"), + currency="HKD", + ) + + for kind, event, payload in ( + ("fee", fee, execution_payload(fee)), + ("funding", funding, execution_payload(funding)), + ("settlement", settlement, execution_payload(settlement)), + ("corporate_action", action, market_event_payload(action)), + ("corporate_action", cash_action, market_event_payload(cash_action)), + ): + assert ( + artifacts_module._ledger_event_from_business_fact( + {"event_kind": kind, "event": payload} + ) + == event + ) + + no_price = replace(settlement, settlement_id="settlement-no-price", settlement_price=None) + assert ( + artifacts_module._ledger_event_from_business_fact( + {"event_kind": "settlement", "event": execution_payload(no_price)} + ) + == no_price + ) + + rich_spec = replace( + ledger().instruments["HK:DIVIDEND"], + effective_to=EX_AT + timedelta(days=365), + superseded_at=EX_AT + timedelta(days=366), + expiry_date=date(2027, 1, 1), + ) + assert ( + artifacts_module._spec_from_payload(artifacts_module._spec_payload(rich_spec)) == rich_spec + ) + + +def test_dividend_replay_payload_parsers_reject_malformed_values() -> None: + for value, message in ( + (None, "fixed-point object"), + ({"units": True, "scale": 2}, "units must be an integer"), + ({"units": 1, "scale": True}, "scale must be an integer"), + ): + with pytest.raises(ValidationError, match=message): + artifacts_module._fixed_from_payload(value, "value") + + for value, message in ( + (None, "ISO-8601 timestamp"), + ("not-a-time", "ISO-8601 timestamp"), + ("2026-01-01T00:00:00", "timezone-aware"), + ): + with pytest.raises(ValidationError, match=message): + artifacts_module._time_from_payload(value, "value") + + with pytest.raises(ValidationError, match="instrument spec replay fact"): + artifacts_module._spec_from_payload(None) + with pytest.raises(ValidationError, match="ledger transaction replay fact"): + artifacts_module._transaction_from_payload(None) + with pytest.raises(ValidationError, match="ledger event business fact"): + artifacts_module._ledger_event_from_business_fact(None) + with pytest.raises(ValidationError, match="ledger event payload"): + artifacts_module._ledger_event_from_business_fact({"event_kind": "fee"}) + with pytest.raises(ValidationError, match="unsupported ledger event"): + artifacts_module._ledger_event_from_business_fact({"event_kind": "unknown", "event": {}}) + with pytest.raises(ValidationError, match="mark business fact must"): + artifacts_module._mark_from_business_fact(None) + with pytest.raises(ValidationError, match="invalid event type"): + artifacts_module._mark_from_business_fact({"event_type": "trade"}) + + with pytest.raises(ValidationError, match="explicit execution mode"): + artifacts_module._dividend_run_metadata(SimpleNamespace(dividend_execution_mode=None)) + with pytest.raises(ValidationError, match="requires EVIDENCED_PIT"): + artifacts_module._dividend_run_metadata( + SimpleNamespace( + dividend_execution_mode=DividendExecutionMode.SCENARIO_ONLY, + fx_valuation_mode=artifacts_module.FxValuationMode.LEGACY, + ) + ) + with pytest.raises(ValidationError, match="requires lifecycle records"): + artifacts_module._dividend_run_metadata( + SimpleNamespace( + dividend_execution_mode=DividendExecutionMode.SCENARIO_ONLY, + fx_valuation_mode=artifacts_module.FxValuationMode.EVIDENCED_PIT, + _dividend_operation_log=[], + ) + ) + assert ( + artifacts_module._trusted_verification_scope( + SimpleNamespace( + dividend_execution_mode=DividendExecutionMode.PRODUCTION_CERTIFIED, + _dividend_execution_records=[], + ) + ) + == [] + ) + + +def _replace_metadata(stored, metadata): + unsigned = deepcopy(metadata) + unsigned.pop("metadata_sha256", None) + metadata["metadata_sha256"] = artifacts_module.hashlib.sha256( + artifacts_module.canonical_bytes(unsigned) + ).hexdigest() + return replace(stored, run_metadata=metadata) + + +def test_replay_rejects_malformed_ordered_business_fact_metadata(tmp_path: Path) -> None: + stored = export_dividend_run(completed_ledger(), tmp_path / "business-fact-guards") + cases = [] + + missing = deepcopy(stored.run_metadata) + missing.pop("business_facts") + cases.append((missing, "ordered business facts are missing")) + wrong_item = deepcopy(stored.run_metadata) + wrong_item["business_facts"] = ["not-an-object"] + cases.append((wrong_item, "ordered business facts are missing")) + invalid_sequence = deepcopy(stored.run_metadata) + invalid_sequence["business_facts"][0]["operation_sequence"] = True + cases.append((invalid_sequence, "operation sequence is invalid")) + noncontiguous = deepcopy(stored.run_metadata) + noncontiguous["business_facts"][0]["operation_sequence"] = 99 + cases.append((noncontiguous, "operation sequence is not contiguous")) + malformed = deepcopy(stored.run_metadata) + malformed["business_facts"][0]["transaction_count_before"] = True + cases.append((malformed, "business fact is malformed")) + unsupported = deepcopy(stored.run_metadata) + unsupported["business_facts"][0]["kind"] = "unsupported" + cases.append((unsupported, "unsupported ordered business fact")) + + for metadata, message in cases: + with pytest.raises(ValidationError, match=message): + replay_dividend_run(_replace_metadata(stored, metadata)) + + +@pytest.mark.parametrize( + ("target", "message"), + [ + ("event_time", "event time mismatch"), + ("marks", "mark state mismatch"), + ("position_lots", "position lots mismatch"), + ("transaction_sha256", "transaction sequence hash mismatch"), + ("dividend_states", "lifecycle state mismatch"), + ("account_snapshot", "account snapshot mismatch"), + ("journal_sha256", "journal hash mismatch"), + ], +) +def test_replay_rejects_tampered_final_comparison_targets( + tmp_path: Path, + target: str, + message: str, +) -> None: + stored = export_dividend_run(completed_ledger(), tmp_path / f"tamper-{target}") + metadata = deepcopy(stored.run_metadata) + final = metadata["final_facts"] + if target == "event_time": + final[target] = datetime(2099, 1, 1, tzinfo=UTC).isoformat() + elif target in {"marks", "dividend_states"}: + final[target] = [] + elif target == "position_lots": + final[target] = {} + elif target == "account_snapshot": + final[target]["account_id"] = "tampered" + else: + final[target] = "0" * 64 + + with pytest.raises(ValidationError, match=message): + replay_dividend_run(_replace_metadata(stored, metadata)) + + +def test_manifest_tampering_is_rejected_even_after_outer_hashes_are_recomputed( + tmp_path: Path, +) -> None: + root = tmp_path / "tampered" + export_dividend_run(completed_ledger(), root) + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["run_metadata"]["initial_conditions"]["account_id"] = "tampered-account" + unsigned_metadata = dict(manifest["run_metadata"]) + unsigned_metadata.pop("metadata_sha256") + manifest["run_metadata"]["metadata_sha256"] = artifacts_module.hashlib.sha256( + artifacts_module.canonical_bytes(unsigned_metadata) + ).hexdigest() + manifest["manifest_sha256"] = artifacts_module._manifest_hash(manifest) + manifest_path.write_bytes(artifacts_module._canonical_manifest_bytes(manifest)) + + loaded = load_stored_artifacts(root) + with pytest.raises(ValidationError, match="initial transaction bytes"): + replay_dividend_run(loaded) + + +@pytest.mark.parametrize( + "failure", + ["append", "commit", "writer", "seal", "candidate", "manifest", "final_load"], +) +def test_export_failures_poison_directory_preserve_source_and_allow_new_root( + tmp_path: Path, + monkeypatch, + failure: str, +) -> None: + account = completed_ledger() + before = account.capture_state() + failed_root = tmp_path / f"failed-{failure}" + + with monkeypatch.context() as scoped: + if failure == "append": + scoped.setattr( + ArrowReplayArtifactSink, + "append", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("append failure")), + ) + elif failure == "commit": + original = ArrowReplayArtifactSink._append_committed + calls = {"count": 0} + + def fail_mid_commit(self, stream, payload): + calls["count"] += 1 + if calls["count"] == 2: + raise RuntimeError("commit failure") + return original(self, stream, payload) + + scoped.setattr(ArrowReplayArtifactSink, "_append_committed", fail_mid_commit) + elif failure == "writer": + scoped.setattr( + ArrowReplayArtifactSink, + "_writer", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("writer failure")), + ) + elif failure == "seal": + scoped.setattr( + ArrowReplayArtifactSink, + "seal", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("seal failure")), + ) + elif failure == "candidate": + scoped.setattr( + artifacts_module, + "replay_dividend_run", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("candidate failure")), + ) + elif failure == "manifest": + original_write = artifacts_module._write_no_clobber + + def fail_manifest(path, body): + if path.name == "manifest.json": + raise RuntimeError("manifest failure") + return original_write(path, body) + + scoped.setattr(artifacts_module, "_write_no_clobber", fail_manifest) + elif failure == "final_load": + scoped.setattr( + artifacts_module, + "load_stored_artifacts", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("close failure")), + ) + + with pytest.raises(RuntimeError): + export_dividend_run(account, failed_root) + + assert account.capture_state() == before + assert (failed_root / "FAILED.json").is_file() + with pytest.raises(ValidationError, match="FAILED"): + load_stored_artifacts(failed_root) + with pytest.raises(FileExistsError): + export_dividend_run(account, failed_root) + + new_root = tmp_path / f"recovered-{failure}" + stored = export_dividend_run(account, new_root) + assert replay_dividend_run(stored).ledger.journal_sha256 == account.journal_sha256 + + +def test_manifest_1_1_sink_cannot_publish_without_candidate_validation(tmp_path: Path) -> None: + sink = ArrowReplayArtifactSink(tmp_path / "unverified", manifest_schema_version="1.1.0") + with pytest.raises(ValidationError, match="candidate replay"): + sink.close({"kind": "unverified"}) + assert (tmp_path / "unverified" / "FAILED.json").is_file() + with pytest.raises(RuntimeError, match="already closed"): + sink.close({"kind": "retry"}, candidate_validator=lambda stored: None) diff --git a/tests/test_dividend_lifecycle.py b/tests/test_dividend_lifecycle.py new file mode 100644 index 0000000..810c1fe --- /dev/null +++ b/tests/test_dividend_lifecycle.py @@ -0,0 +1,1805 @@ +from __future__ import annotations + +from copy import deepcopy +from dataclasses import replace +from datetime import date, datetime, timedelta, timezone +from decimal import ( + ROUND_DOWN, + ROUND_HALF_EVEN, + ROUND_HALF_UP, + ROUND_UP, + Decimal, + Inexact, + Rounded, + localcontext, +) +from fractions import Fraction +from types import SimpleNamespace + +import pytest +from conftest import fp, spec +from quant_data_kit import AssetClass, FixedPoint +from quant_data_kit.exceptions import ValidationError +from quant_data_kit.financial import ( + CashDeduction, + CurrencyReference, + DividendEntitlement, + DividendLifecycle, + DividendPayment, + DividendPaymentElection, + EvidenceTiming, + IssuerFxConversion, + PaymentPolicy, + PhaseEvidence, + PitFxRate, + PublishedAmount, + RoundingPolicy, +) + +import quant_execution.dividends as dividends_module +from quant_execution.contracts import LedgerEventType, LedgerTransaction, Posting +from quant_execution.dividends import ( + DividendEntitlementBasis, + DividendExecutionMode, + DividendExecutionPhase, + DividendExecutionRequest, + DividendValuationRecord, + FxValuationMode, + PitFxObservationRecord, +) +from quant_execution.ledger import ExactAccountLedger + +UTC = timezone.utc +OPENED = datetime(2026, 1, 1, tzinfo=UTC) +EX_AT = datetime(2026, 1, 2, tzinfo=UTC) +STOCK = "HK:DIVIDEND" + + +def stamp(value: datetime) -> str: + return value.isoformat().replace("+00:00", "Z") + + +def evidence( + event_id: str, + *, + effective_at: datetime, + available_at: datetime, + captured_at: datetime | None = None, +) -> PhaseEvidence: + captured = captured_at or available_at + return PhaseEvidence( + event_id=event_id, + source="issuer-feed", + evidence_id=f"evidence:{event_id}", + timing=EvidenceTiming( + effective_at=stamp(effective_at), + available_at=stamp(available_at), + captured_at=stamp(captured), + source_published_at=stamp(available_at), + ), + ) + + +def amount( + value: str, + source_units: str = "1", + *, + approximate: bool = False, +) -> PublishedAmount: + return PublishedAmount( + amount_text=value, + source_unit_text=source_units, + source_unit_name="share", + published_decimal_places=len(value.partition(".")[2]), + approximate=approximate, + ) + + +def currency(code: str) -> CurrencyReference: + return CurrencyReference( + source_label=code, + calculation_currency=code, + normalization_rule="identity", + ) + + +def entitlement( + *, + event_id: str = "entitlement", + declared: str = "HKD", + approved: str = "1.00", + source_units: str = "1", + available_at: datetime = EX_AT - timedelta(hours=1), + captured_at: datetime = EX_AT + timedelta(days=1), + options: tuple[str, ...] | None = None, +) -> DividendEntitlement: + payment_options = options or (declared,) + return DividendEntitlement( + evidence=evidence( + event_id, + effective_at=EX_AT, + available_at=available_at, + captured_at=captured_at, + ), + approved_amount=amount(approved, source_units), + declared_currency=currency(declared), + record_date="2026-01-03", + scheduled_payment_date="2026-01-10", + payment_currencies=tuple(currency(item) for item in payment_options), + default_payment_currency=payment_options[0], + ) + + +def election( + payment_currency: str, + *, + policy_id: str = "policy-1", + at: datetime = EX_AT + timedelta(days=1), +) -> DividendPaymentElection: + return DividendPaymentElection( + evidence=evidence("election", effective_at=at, available_at=at), + account_id="account", + account_policy_id=policy_id, + payment_currency=payment_currency, + selection_kind="explicit", + ) + + +def certified_policy( + *, + policy_id: str = "policy-1", + decimal_places: int = 2, + scope: str = "aggregate_account", + at: datetime = EX_AT + timedelta(days=1), +) -> PaymentPolicy: + return PaymentPolicy( + policy_id=policy_id, + account_id="account", + certification_status="certified", + holder_tax_profile_id="holder-tax-1", + withholding_rule_id="withholding-rule-1", + rounding=RoundingPolicy( + scope=scope, + decimal_places=decimal_places, + mode="ROUND_HALF_UP", + evidence_id="rounding-evidence", + ), + evidence=evidence("policy", effective_at=at, available_at=at), + ) + + +def issuer_conversion( + *, + source: str = "CNY", + target: str = "HKD", + rate: str = "0.9", + published: str = "0.90", + source_units: str = "1", + approximate: bool = False, + at: datetime = EX_AT + timedelta(days=1), +) -> IssuerFxConversion: + return IssuerFxConversion( + evidence=evidence("conversion", effective_at=at, available_at=at), + from_currency=source, + to_currency=target, + rate_text=rate, + rate_convention="quote_per_base", + fixing_at=stamp(at), + fixing_date=None, + fixing_timezone=None, + published_payment_amount=amount( + published, + source_units, + approximate=approximate, + ), + ) + + +def payment( + *, + gross: str, + net: str, + payment_currency: str = "HKD", + withholding: str = "0", + deductions: tuple[CashDeduction, ...] = (), + rounding: str = "0", + policy_id: str = "policy-1", + at: datetime = EX_AT + timedelta(days=2), +) -> DividendPayment: + return DividendPayment( + evidence=evidence("payment", effective_at=at, available_at=at), + account_id="account", + payment_currency=payment_currency, + policy_id=policy_id, + gross_cash_text=gross, + withholding_cash_text=withholding, + deductions=deductions, + rounding_adjustment_text=rounding, + net_cash_text=net, + ) + + +def lifecycle( + dividend_id: str = "dividend-ordinary", + *, + terms: DividendEntitlement | None = None, + choice: DividendPaymentElection | None = None, + conversion: IssuerFxConversion | None = None, + policy: PaymentPolicy | None = None, + paid: DividendPayment | None = None, +) -> DividendLifecycle: + return DividendLifecycle( + dividend_id=dividend_id, + instrument_id=STOCK, + entitlement=terms or entitlement(), + election=choice, + conversion=conversion, + payment_policy=policy, + payment=paid, + ) + + +def basis( + dividend_id: str = "dividend-ordinary", + *, + quantity: FixedPoint | None = None, + available_at: datetime = EX_AT - timedelta(hours=2), + captured_at: datetime = EX_AT + timedelta(days=2), + certification_ref: str | None = None, +) -> DividendEntitlementBasis: + entitled_quantity = quantity if quantity is not None else FixedPoint(3, 0) + return DividendEntitlementBasis( + account_id="account", + dividend_id=dividend_id, + instrument_id=STOCK, + ex_at=EX_AT, + entitled_quantity=entitled_quantity, + available_at=available_at, + captured_at=captured_at, + evidence_id=f"basis:{dividend_id}", + evidence_source="custodian-statement", + certification_ref=certification_ref, + ) + + +def ledger( + *, + mode: DividendExecutionMode = DividendExecutionMode.SCENARIO_ONLY, + verifier=None, + money_scale: int = 8, +) -> ExactAccountLedger: + account = ExactAccountLedger( + account_id="account", + base_currency="HKD", + instruments={ + STOCK: spec( + STOCK, + asset_class=AssetClass.EQUITY, + product_type="cash_equity", + settlement_currency="HKD", + ) + }, + initial_cash={"HKD": fp("100")}, + money_scale=money_scale, + opened_at=OPENED, + dividend_execution_mode=mode, + fx_valuation_mode=FxValuationMode.EVIDENCED_PIT, + entitlement_evidence_verifier=verifier, + ) + account.book_opening_position( + instrument_id=STOCK, + quantity=FixedPoint(3, 0), + average_cost=fp("10"), + acquired_on=date(2025, 12, 31), + ) + return account + + +def request( + value: DividendLifecycle, + phase: DividendExecutionPhase, + *, + cutoff: datetime, + evidence_basis: DividendEntitlementBasis | None = None, +) -> DividendExecutionRequest: + return DividendExecutionRequest( + lifecycle=value, + phase=phase, + cutoff=cutoff, + entitlement_basis=evidence_basis, + ) + + +def apply_entitlement( + account: ExactAccountLedger, + value: DividendLifecycle | None = None, + evidence_basis: DividendEntitlementBasis | None = None, +): + value = value or lifecycle() + return account.apply_dividend_lifecycle( + request( + value, + DividendExecutionPhase.ENTITLEMENT, + cutoff=EX_AT, + evidence_basis=evidence_basis or basis(value.dividend_id), + ) + ) + + +def same_currency_prefix(account: ExactAccountLedger) -> DividendLifecycle: + initial = lifecycle() + apply_entitlement(account, initial) + value = lifecycle( + choice=election("HKD"), + policy=certified_policy(), + ) + account.apply_dividend_lifecycle( + request( + value, + DividendExecutionPhase.ISSUER_CONVERSION, + cutoff=EX_AT + timedelta(days=1), + ) + ) + return value + + +def pit_rate( + event_id: str, + *, + base: str, + rate: str, + observed_at: datetime, + available_at: datetime | None = None, +) -> PitFxRate: + available = available_at or observed_at + return PitFxRate( + event_id=event_id, + base_currency=base, + quote_currency="HKD", + rate_text=rate, + rate_convention="quote_per_base", + observed_at=stamp(observed_at), + available_at=stamp(available), + captured_at=stamp(max(available, EX_AT + timedelta(days=10))), + source="fx-source", + evidence_id=f"evidence:{event_id}", + ) + + +def test_entitlement_aggregates_fraction_before_exact_fixed_point_conversion() -> None: + account = ledger() + value = lifecycle(terms=entitlement(approved="0.10", source_units="3")) + + record = apply_entitlement(account, value) + + state = account._dividend_lifecycle_states[("account", value.dividend_id)] + assert state.declared_gross == FixedPoint(10_000_000, 8) + assert account.dividend_receivable_balance("HKD") == Decimal("0.10000000") + assert record.transaction_ids + assert account.snapshot(EX_AT).nav.to_decimal() == Decimal("130.10000000") + + +def test_nested_record_views_cannot_mutate_ledger_state_or_journal() -> None: + account = ledger() + record = apply_entitlement(account) + state = account._dividend_lifecycle_states[("account", "dividend-ordinary")] + journal_before = account.journal_sha256 + + record_payload = record.to_dict() + record_payload["lifecycle_snapshot"]["entitlement"]["approved_amount"]["amount_text"] = "999.00" + state_payload = state.to_dict() + state_payload["lifecycle_snapshot"]["entitlement"]["approved_amount"]["amount_text"] = "888.00" + + with pytest.raises(TypeError): + record.lifecycle_snapshot["entitlement"]["approved_amount"]["amount_text"] = "777.00" + with pytest.raises(TypeError): + record.lifecycle_snapshot["entitlement"]["payment_currencies"][0] = {} + + assert ( + record.to_dict()["lifecycle_snapshot"]["entitlement"]["approved_amount"]["amount_text"] + == "1.00" + ) + assert ( + state.to_dict()["lifecycle_snapshot"]["entitlement"]["approved_amount"]["amount_text"] + == "1.00" + ) + assert account.dividend_receivable_balance("HKD") == Decimal("3.00000000") + assert account.journal_sha256 == journal_before + assert deepcopy(record) == record + + +def test_public_record_constructors_take_recursive_ownership_of_inputs() -> None: + rate_payload = {"event_id": "rate", "nested": {"source": "before"}, "items": [1]} + rate_record = PitFxObservationRecord( + observation_sequence=0, + rate_payload=rate_payload, + rate_fingerprint="0" * 64, + operation_sequence=0, + transaction_count=0, + ) + result_payload = {"nested": {"value": "before"}, "items": [{"value": 1}]} + selected_rate = {"nested": {"value": "before"}} + valuation_record = DividendValuationRecord( + valuation_idempotency_key="valuation", + as_of=EX_AT, + execution_mode=DividendExecutionMode.SCENARIO_ONLY, + fx_valuation_mode=FxValuationMode.EVIDENCED_PIT, + selected_rates=(selected_rate,), + result_payload=result_payload, + result_sha256="1" * 64, + operation_sequence=1, + transaction_count=0, + ) + + rate_payload["nested"]["source"] = "after" + rate_payload["items"].append(2) + result_payload["nested"]["value"] = "after" + result_payload["items"][0]["value"] = 2 + selected_rate["nested"]["value"] = "after" + + assert rate_record.to_dict()["rate_payload"] == { + "event_id": "rate", + "nested": {"source": "before"}, + "items": [1], + } + assert valuation_record.to_dict()["result_payload"] == { + "nested": {"value": "before"}, + "items": [{"value": 1}], + } + assert valuation_record.to_dict()["selected_rates"] == [{"nested": {"value": "before"}}] + with pytest.raises(TypeError): + rate_record.rate_payload["nested"]["source"] = "blocked" + with pytest.raises(TypeError): + valuation_record.result_payload["items"][0]["value"] = 3 + + +def test_dividend_contract_parsers_and_recursive_values_fail_closed() -> None: + frozen = dividends_module._freeze({"items": [{"value": 1}]}) + assert dividends_module._freeze(frozen) is frozen + assert len(frozen) == 1 + assert repr(frozen) + sequence = frozen["items"] + assert len(sequence) == 1 + assert sequence == [{"value": 1}] + assert sequence != "not-a-sequence" + assert repr(sequence) + assert deepcopy(sequence) is sequence + + with pytest.raises(ValidationError, match="non-empty string"): + dividends_module._text(" ", "value") + with pytest.raises(ValidationError, match="ISO-8601 timestamp"): + dividends_module._parse_timestamp(None, "value") + for value, message in ( + (None, "fixed-point object"), + ({"units": True, "scale": 1}, "units must be an integer"), + ({"units": 1, "scale": True}, "scale must be an integer"), + ): + with pytest.raises(ValidationError, match=message): + dividends_module._fixed_from_payload(value, "value") + + with pytest.raises(ValidationError, match="nonnegative FixedPoint"): + replace(basis(), entitled_quantity="invalid") + with pytest.raises(ValidationError, match="nonnegative FixedPoint"): + replace(basis(), entitled_quantity=FixedPoint(-1, 0)) + with pytest.raises(ValidationError, match="entitlement_basis must be an object"): + DividendEntitlementBasis.from_dict(None) + + with pytest.raises(ValidationError, match="lifecycle must be"): + DividendExecutionRequest( + lifecycle="invalid", + phase=DividendExecutionPhase.ENTITLEMENT, + cutoff=EX_AT, + ) + with pytest.raises(ValidationError, match="phase must be"): + DividendExecutionRequest( + lifecycle=lifecycle(), + phase="entitlement", + cutoff=EX_AT, + ) + with pytest.raises(ValidationError, match="entitlement_basis has an invalid type"): + DividendExecutionRequest( + lifecycle=lifecycle(), + phase=DividendExecutionPhase.ENTITLEMENT, + cutoff=EX_AT, + entitlement_basis="invalid", + ) + + account = ledger() + phase_record = apply_entitlement(account) + state = account._dividend_lifecycle_states[("account", "dividend-ordinary")] + for constructor, message in ( + (dividends_module.DividendLifecycleState.from_dict, "lifecycle state"), + (dividends_module.DividendExecutionRecord.from_dict, "execution record"), + (dividends_module.PitFxObservationRecord.from_dict, "observation record"), + (dividends_module.DividendValuationRecord.from_dict, "valuation record"), + ): + with pytest.raises(ValidationError, match=message): + constructor(None) + assert dividends_module.DividendLifecycleState.from_dict(state.to_dict()) == state + record_payload = phase_record.to_dict() + with pytest.raises(ValidationError, match="unsupported dividend record schema"): + dividends_module.DividendExecutionRecord.from_dict( + {**record_payload, "schema": "unsupported"} + ) + with pytest.raises(ValidationError, match="not a dividend phase application"): + dividends_module.DividendExecutionRecord.from_dict( + {**record_payload, "record_kind": "valuation"} + ) + with pytest.raises(ValidationError, match="dividend record must be an object"): + dividends_module.dividend_record_from_dict(None) + with pytest.raises(ValidationError, match="unsupported dividend record kind"): + dividends_module.dividend_record_from_dict({"record_kind": "unknown"}) + + +def test_exact_dividend_numeric_helpers_cover_rounding_and_overflow_boundaries() -> None: + with pytest.raises(ValidationError, match="decimal text"): + dividends_module._fraction_text(None, "value") + with pytest.raises(ValidationError, match="must be finite"): + dividends_module._fraction_text("NaN", "value") + with pytest.raises(ValidationError, match="NOT_EXACT"): + dividends_module._fixed_exact(Fraction(1, 3), 2, "NOT_EXACT") + with pytest.raises(ValidationError, match="FIXED_POINT_OVERFLOW"): + dividends_module._fixed_exact(Fraction(2**63), 0, "NOT_EXACT") + + assert dividends_module._round_fraction(Fraction(1, 4), 1, ROUND_DOWN) == Fraction(1, 5) + assert dividends_module._round_fraction(Fraction(-1, 4), 1, ROUND_UP) == Fraction(-3, 10) + assert dividends_module._round_fraction(Fraction(1, 4), 1, ROUND_HALF_UP) == Fraction(3, 10) + assert dividends_module._round_fraction(Fraction(1, 4), 1, ROUND_HALF_EVEN) == Fraction(1, 5) + assert dividends_module._round_fraction(Fraction(3, 4), 1, ROUND_HALF_EVEN) == Fraction(4, 5) + assert dividends_module._round_fraction(Fraction(1, 2), 1, "unused") == Fraction(1, 2) + with pytest.raises(ValidationError, match="unsupported rounding mode"): + dividends_module._round_fraction(Fraction(1, 4), 1, "unsupported") + + assert dividends_module._certified_rounding_policy(None, account_id="account") is None + with pytest.raises(ValidationError, match="PAYMENT_POLICY_ACCOUNT_MISMATCH"): + dividends_module._certified_rounding_policy( + SimpleNamespace(account_id="other", certification_status="certified"), + account_id="account", + ) + assert ( + dividends_module._certified_rounding_policy( + SimpleNamespace(account_id="account", certification_status="unverified"), + account_id="account", + ) + is None + ) + with pytest.raises(ValidationError, match="ISSUER_CONVERSION_REQUIRED"): + dividends_module._conversion_account_amount(lifecycle(), Fraction(1)) + with pytest.raises(ValidationError, match="ISSUER_CONVERSION_REQUIRED"): + dividends_module._issuer_conversion_audit(lifecycle(), Fraction(1)) + + +def test_dividend_phase_entry_guards_reject_invalid_mode_identity_order_and_cutoff() -> None: + with pytest.raises(ValidationError, match="request must be"): + ledger().apply_dividend_lifecycle("invalid") + + no_mode = ledger() + no_mode._dividend_execution_mode = None + with pytest.raises(ValidationError, match="DIVIDEND_EXECUTION_MODE_REQUIRED"): + apply_entitlement(no_mode) + + legacy_fx = ledger() + legacy_fx._fx_valuation_mode = FxValuationMode.LEGACY + with pytest.raises(ValidationError, match="EVIDENCED_PIT_REQUIRED"): + apply_entitlement(legacy_fx) + + with pytest.raises(ValidationError, match="UNKNOWN_DIVIDEND_INSTRUMENT"): + apply_entitlement( + ledger(), + replace(lifecycle(), instrument_id="UNKNOWN"), + ) + + missing_basis = ledger() + with pytest.raises(ValidationError, match="ENTITLEMENT_BASIS_REQUIRED"): + missing_basis.apply_dividend_lifecycle( + request( + lifecycle(), + DividendExecutionPhase.ENTITLEMENT, + cutoff=EX_AT, + ) + ) + + identity_mismatch = ledger() + with pytest.raises(ValidationError, match="ENTITLEMENT_BASIS_IDENTITY_MISMATCH"): + apply_entitlement( + identity_mismatch, + evidence_basis=replace(basis(), account_id="other"), + ) + + cutoff_mismatch = ledger() + early_entitlement = replace( + entitlement(), + evidence=evidence( + "entitlement-early", + effective_at=EX_AT - timedelta(hours=1), + available_at=EX_AT - timedelta(hours=1), + ), + ) + with pytest.raises(ValidationError, match="ENTITLEMENT_CUTOFF_PRECEDES_EX_AT"): + cutoff_mismatch.apply_dividend_lifecycle( + request( + lifecycle(terms=early_entitlement), + DividendExecutionPhase.ENTITLEMENT, + cutoff=EX_AT - timedelta(minutes=30), + evidence_basis=basis(), + ) + ) + + no_parent = ledger() + with pytest.raises(ValidationError, match="DIVIDEND_PHASE_ORDER_INVALID"): + no_parent.apply_dividend_lifecycle( + request( + lifecycle(choice=election("HKD"), policy=certified_policy()), + DividendExecutionPhase.ISSUER_CONVERSION, + cutoff=EX_AT + timedelta(days=1), + ) + ) + + skipped_conversion = ledger() + apply_entitlement(skipped_conversion) + with pytest.raises(ValidationError, match="DIVIDEND_PHASE_ORDER_INVALID"): + skipped_conversion.apply_dividend_lifecycle( + request( + lifecycle( + choice=election("HKD"), + policy=certified_policy(), + paid=payment(gross="3", net="3"), + ), + DividendExecutionPhase.PAYMENT, + cutoff=EX_AT + timedelta(days=2), + ) + ) + + +def test_dividend_observation_and_valuation_type_guards_fail_closed() -> None: + legacy = SimpleNamespace(fx_valuation_mode=FxValuationMode.LEGACY) + with pytest.raises(ValidationError, match="EVIDENCED_PIT_REQUIRED"): + dividends_module.observe_pit_fx(legacy, None) + with pytest.raises(ValidationError, match="rate must be a PitFxRate"): + ledger().observe_pit_fx("invalid") + with pytest.raises(ValidationError, match="finite Decimal or FixedPoint"): + dividends_module.convert_for_valuation( + SimpleNamespace(fx_valuation_mode=FxValuationMode.EVIDENCED_PIT), + "invalid", + "HKD", + EX_AT, + ) + with pytest.raises(ValidationError, match="finite Decimal or FixedPoint"): + dividends_module.convert_for_valuation( + SimpleNamespace(fx_valuation_mode=FxValuationMode.EVIDENCED_PIT), + Decimal("NaN"), + "HKD", + EX_AT, + ) + with pytest.raises(ValidationError, match="EVIDENCED_PIT_REQUIRED"): + dividends_module.record_dividend_valuation(legacy, as_of=EX_AT) + + zero_account = ledger() + apply_entitlement( + zero_account, + lifecycle(terms=entitlement(approved="0")), + ) + assert zero_account.dividend_exposure(as_of=EX_AT).items == () + + +def test_aggregate_rounding_happens_once_after_account_quantity_is_known() -> None: + account = ledger() + value = lifecycle( + terms=entitlement(approved="0.05", source_units="2"), + policy=certified_policy(at=EX_AT - timedelta(minutes=1)), + ) + + apply_entitlement(account, value) + + state = account._dividend_lifecycle_states[("account", value.dividend_id)] + assert state.declared_gross.to_decimal() == Decimal("0.08000000") + assert state.declared_gross.to_decimal() != Decimal("0.09") + + +def test_stable_identity_allows_ordinary_and_special_same_day_and_is_idempotent() -> None: + account = ledger() + ordinary = lifecycle("ordinary") + special = lifecycle("special", terms=entitlement(event_id="special-entitlement")) + + first = apply_entitlement(account, ordinary, basis("ordinary")) + second = apply_entitlement(account, special, basis("special")) + + assert apply_entitlement(account, ordinary, basis("ordinary")) == first + assert first.phase_fingerprint != second.phase_fingerprint + assert account.dividend_receivable_balance("HKD") == Decimal("6.00000000") + assert len(account._dividend_lifecycle_states) == 2 + + +@pytest.mark.parametrize( + "mode", + [DividendExecutionMode.SCENARIO_ONLY, DividendExecutionMode.PRODUCTION_CERTIFIED], +) +def test_idempotent_phase_retries_cannot_return_facts_after_request_cutoff( + mode: DividendExecutionMode, +) -> None: + class Verifier: + def verify_entitlement_basis(self, *, basis, lifecycle) -> bool: + return True + + def verify_payment_policy(self, *, policy, lifecycle) -> bool: + return True + + def verify_dividend_payment(self, *, payment, lifecycle) -> bool: + return True + + verifier = Verifier() if mode is DividendExecutionMode.PRODUCTION_CERTIFIED else None + account = ledger(mode=mode, verifier=verifier) + entitlement_terms = replace( + entitlement(), + evidence=evidence( + "entitlement-early-effective", + effective_at=EX_AT - timedelta(hours=1), + available_at=EX_AT - timedelta(hours=1), + ), + ) + initial = lifecycle(terms=entitlement_terms) + evidence_basis = basis(certification_ref="basis-certified") + entitlement_record = account.apply_dividend_lifecycle( + request( + initial, + DividendExecutionPhase.ENTITLEMENT, + cutoff=EX_AT, + evidence_basis=evidence_basis, + ) + ) + + conversion_at = EX_AT + timedelta(days=1) + conversion_choice = replace( + election("HKD", at=conversion_at), + evidence=evidence( + "conversion-election", + effective_at=conversion_at, + available_at=conversion_at - timedelta(hours=1), + ), + ) + conversion_prefix = lifecycle( + terms=entitlement_terms, + choice=conversion_choice, + policy=certified_policy(at=EX_AT), + ) + conversion_record = account.apply_dividend_lifecycle( + request( + conversion_prefix, + DividendExecutionPhase.ISSUER_CONVERSION, + cutoff=conversion_at, + ) + ) + + payment_at = EX_AT + timedelta(days=2) + actual_payment = payment(gross="3", net="3", at=payment_at) + paid = replace(conversion_prefix, payment=actual_payment) + payment_record = account.apply_dividend_lifecycle( + request( + paid, + DividendExecutionPhase.PAYMENT, + cutoff=payment_at, + ) + ) + + retries = ( + ( + initial, + DividendExecutionPhase.ENTITLEMENT, + evidence_basis, + entitlement_record, + EX_AT - timedelta(minutes=30), + "DIVIDEND_PHASE_APPLIED_AFTER_CUTOFF", + ), + ( + conversion_prefix, + DividendExecutionPhase.ISSUER_CONVERSION, + None, + conversion_record, + conversion_at - timedelta(minutes=30), + "DIVIDEND_FACT_NOT_EFFECTIVE_AT_CUTOFF", + ), + ( + paid, + DividendExecutionPhase.PAYMENT, + None, + payment_record, + payment_at - timedelta(minutes=30), + "FUTURE_DIVIDEND_FACT_IN_REQUEST", + ), + ) + for value, phase, phase_basis, prior_record, early_cutoff, message in retries: + for accepted_cutoff in (prior_record.cutoff, prior_record.cutoff + timedelta(days=1)): + assert ( + account.apply_dividend_lifecycle( + request( + value, + phase, + cutoff=accepted_cutoff, + evidence_basis=phase_basis, + ) + ) + == prior_record + ) + before = account.capture_state() + with pytest.raises(ValidationError, match=message): + account.apply_dividend_lifecycle( + request( + value, + phase, + cutoff=early_cutoff, + evidence_basis=phase_basis, + ) + ) + assert account.capture_state() == before + + +def test_same_phase_identity_conflict_rolls_back_everything() -> None: + account = ledger() + value = lifecycle() + apply_entitlement(account, value) + before = account.capture_state() + changed = lifecycle(terms=entitlement(approved="2.00")) + + with pytest.raises(ValidationError, match="DIVIDEND_PHASE_ID_REUSED"): + apply_entitlement(account, changed) + + assert account.capture_state() == before + + +def test_future_optional_fact_is_not_silently_trimmed_from_cutoff_snapshot() -> None: + account = ledger() + value = lifecycle(choice=election("HKD")) + before = account.capture_state() + + with pytest.raises(ValidationError, match="FUTURE_DIVIDEND_FACT_IN_REQUEST"): + apply_entitlement(account, value) + + assert account.capture_state() == before + + +@pytest.mark.parametrize( + ("terms", "evidence_basis", "message"), + [ + ( + entitlement( + available_at=EX_AT + timedelta(seconds=1), captured_at=EX_AT + timedelta(days=1) + ), + basis(), + "LATE_ENTITLEMENT_UNSUPPORTED", + ), + ( + entitlement(), + basis(available_at=EX_AT + timedelta(seconds=1), captured_at=EX_AT + timedelta(days=1)), + "LATE_ENTITLEMENT_UNSUPPORTED", + ), + (entitlement(), basis(quantity=FixedPoint(2, 0)), "ENTITLEMENT_BASIS_MISMATCH"), + ], +) +def test_entitlement_timing_and_real_position_rejections_are_atomic( + terms: DividendEntitlement, + evidence_basis: DividendEntitlementBasis, + message: str, +) -> None: + account = ledger() + before = account.capture_state() + + with pytest.raises(ValidationError, match=message): + apply_entitlement(account, lifecycle(terms=terms), evidence_basis) + + assert account.capture_state() == before + + +def test_ledger_clock_cannot_move_back_to_ex_date() -> None: + account = ledger() + account._event_time = EX_AT + timedelta(seconds=1) + before = account.capture_state() + + with pytest.raises(ValidationError, match="LEDGER_PASSED_EX_AT"): + apply_entitlement(account) + + assert account.capture_state() == before + + +def test_production_mode_requires_independent_verifier_and_accepts_archived_capture() -> None: + class Verifier: + def verify_entitlement_basis(self, *, basis, lifecycle) -> bool: + return basis.certification_ref == "certified:historical" and bool(lifecycle.dividend_id) + + account = ledger(mode=DividendExecutionMode.PRODUCTION_CERTIFIED, verifier=Verifier()) + accepted = basis(certification_ref="certified:historical") + assert accepted.available_at <= accepted.ex_at < accepted.captured_at + assert apply_entitlement(account, evidence_basis=accepted) + + rejected = ledger(mode=DividendExecutionMode.PRODUCTION_CERTIFIED, verifier=Verifier()) + before = rejected.capture_state() + with pytest.raises(ValidationError, match="ENTITLEMENT_EVIDENCE_NOT_CERTIFIED"): + apply_entitlement(rejected, evidence_basis=basis(certification_ref="request-self-claim")) + assert rejected.capture_state() == before + + +def test_production_policy_is_verified_when_first_used() -> None: + class EntitlementOnlyVerifier: + def verify_entitlement_basis(self, *, basis, lifecycle) -> bool: + return True + + account = ledger( + mode=DividendExecutionMode.PRODUCTION_CERTIFIED, + verifier=EntitlementOnlyVerifier(), + ) + apply_entitlement(account, evidence_basis=basis(certification_ref="basis-certified")) + before = account.capture_state() + value = lifecycle(choice=election("HKD"), policy=certified_policy()) + + with pytest.raises(ValidationError, match="PAYMENT_POLICY_EVIDENCE_NOT_CERTIFIED"): + account.apply_dividend_lifecycle( + request( + value, + DividendExecutionPhase.ISSUER_CONVERSION, + cutoff=EX_AT + timedelta(days=1), + ) + ) + + assert account.capture_state() == before + + +def test_production_actual_payment_requires_separate_trusted_verification() -> None: + class PolicyVerifier: + def verify_entitlement_basis(self, *, basis, lifecycle) -> bool: + return True + + def verify_payment_policy(self, *, policy, lifecycle) -> bool: + return policy.policy_id == "policy-1" + + def verify_dividend_payment(self, *, payment, lifecycle) -> bool: + return False + + account = ledger( + mode=DividendExecutionMode.PRODUCTION_CERTIFIED, + verifier=PolicyVerifier(), + ) + apply_entitlement(account, evidence_basis=basis(certification_ref="basis-certified")) + prefix = lifecycle(choice=election("HKD"), policy=certified_policy()) + account.apply_dividend_lifecycle( + request( + prefix, + DividendExecutionPhase.ISSUER_CONVERSION, + cutoff=EX_AT + timedelta(days=1), + ) + ) + before = account.capture_state() + + with pytest.raises(ValidationError, match="DIVIDEND_PAYMENT_EVIDENCE_NOT_CERTIFIED"): + account.apply_dividend_lifecycle( + request( + replace(prefix, payment=payment(gross="3", net="3")), + DividendExecutionPhase.PAYMENT, + cutoff=EX_AT + timedelta(days=2), + ) + ) + + assert account.capture_state() == before + + +@pytest.mark.parametrize( + "mode", + [DividendExecutionMode.SCENARIO_ONLY, DividendExecutionMode.PRODUCTION_CERTIFIED], +) +def test_future_effective_policy_cannot_round_entitlement_before_ex( + mode: DividendExecutionMode, +) -> None: + class Verifier: + def verify_entitlement_basis(self, *, basis, lifecycle) -> bool: + return True + + def verify_payment_policy(self, *, policy, lifecycle) -> bool: + return True + + verifier = Verifier() if mode is DividendExecutionMode.PRODUCTION_CERTIFIED else None + account = ledger(mode=mode, verifier=verifier) + future_policy = replace( + certified_policy(decimal_places=0), + evidence=evidence( + "future-policy", + effective_at=EX_AT + timedelta(days=5), + available_at=EX_AT - timedelta(hours=1), + captured_at=EX_AT + timedelta(days=1), + ), + ) + value = lifecycle( + terms=entitlement(approved="1.01"), + policy=future_policy, + ) + before = account.capture_state() + + with pytest.raises(ValidationError, match="DIVIDEND_FACT_NOT_EFFECTIVE"): + account.apply_dividend_lifecycle( + request( + value, + DividendExecutionPhase.ENTITLEMENT, + cutoff=EX_AT + timedelta(days=10), + evidence_basis=basis(certification_ref="basis-certified"), + ) + ) + + assert account.capture_state() == before + assert account.dividend_receivable_balance("HKD") == 0 + + +@pytest.mark.parametrize( + "mode", + [DividendExecutionMode.SCENARIO_ONLY, DividendExecutionMode.PRODUCTION_CERTIFIED], +) +def test_policy_learned_after_ex_cannot_rewrite_entitlement_receivable( + mode: DividendExecutionMode, +) -> None: + class Verifier: + def verify_entitlement_basis(self, *, basis, lifecycle) -> bool: + return True + + def verify_payment_policy(self, *, policy, lifecycle) -> bool: + return True + + verifier = Verifier() if mode is DividendExecutionMode.PRODUCTION_CERTIFIED else None + account = ledger(mode=mode, verifier=verifier) + learned_late_policy = replace( + certified_policy(decimal_places=0), + evidence=evidence( + "late-policy", + effective_at=EX_AT - timedelta(hours=1), + available_at=EX_AT + timedelta(days=1), + captured_at=EX_AT + timedelta(days=2), + ), + ) + value = lifecycle( + terms=entitlement(approved="1.01"), + policy=learned_late_policy, + ) + before = account.capture_state() + + with pytest.raises(ValidationError, match="LATE_ENTITLEMENT_UNSUPPORTED"): + account.apply_dividend_lifecycle( + request( + value, + DividendExecutionPhase.ENTITLEMENT, + cutoff=EX_AT + timedelta(days=10), + evidence_basis=basis(certification_ref="basis-certified"), + ) + ) + + assert account.capture_state() == before + assert account.dividend_receivable_balance("HKD") == 0 + + +@pytest.mark.parametrize( + "mode", + [DividendExecutionMode.SCENARIO_ONLY, DividendExecutionMode.PRODUCTION_CERTIFIED], +) +def test_future_effective_election_cannot_advance_ledger_beyond_cutoff( + mode: DividendExecutionMode, +) -> None: + class Verifier: + def verify_entitlement_basis(self, *, basis, lifecycle) -> bool: + return True + + def verify_payment_policy(self, *, policy, lifecycle) -> bool: + return True + + verifier = Verifier() if mode is DividendExecutionMode.PRODUCTION_CERTIFIED else None + account = ledger(mode=mode, verifier=verifier) + initial = lifecycle() + apply_entitlement( + account, + initial, + basis(certification_ref="basis-certified"), + ) + cutoff = EX_AT + timedelta(days=1) + future_election = replace( + election("HKD"), + evidence=evidence( + "future-election", + effective_at=EX_AT + timedelta(days=5), + available_at=cutoff, + captured_at=cutoff, + ), + ) + value = lifecycle( + terms=initial.entitlement, + choice=future_election, + policy=certified_policy(at=cutoff), + ) + before = account.capture_state() + + with pytest.raises(ValidationError, match="DIVIDEND_FACT_NOT_EFFECTIVE_AT_CUTOFF"): + account.apply_dividend_lifecycle( + request( + value, + DividendExecutionPhase.ISSUER_CONVERSION, + cutoff=cutoff, + ) + ) + + assert account.capture_state() == before + + +def test_basis_capture_cannot_precede_availability() -> None: + with pytest.raises(ValidationError, match="BASIS_CAPTURE_PRECEDES_AVAILABILITY"): + basis( + available_at=EX_AT - timedelta(hours=1), + captured_at=EX_AT - timedelta(hours=2), + ) + + +def test_same_currency_election_has_no_fake_conversion_transaction() -> None: + account = ledger() + apply_entitlement(account) + before = len(account.transactions) + value = lifecycle(choice=election("HKD"), policy=certified_policy()) + + record = account.apply_dividend_lifecycle( + request( + value, + DividendExecutionPhase.ISSUER_CONVERSION, + cutoff=EX_AT + timedelta(days=1), + ) + ) + + assert record.transaction_ids == () + assert len(account.transactions) == before + + +def test_issuer_conversion_uses_published_amount_and_balances_each_currency() -> None: + account = ledger() + initial = lifecycle( + terms=entitlement(declared="CNY", options=("CNY", "HKD")), + ) + apply_entitlement(account, initial) + value = lifecycle( + terms=initial.entitlement, + choice=election("HKD"), + conversion=issuer_conversion(), + policy=certified_policy(), + ) + + record = account.apply_dividend_lifecycle( + request( + value, + DividendExecutionPhase.ISSUER_CONVERSION, + cutoff=EX_AT + timedelta(days=1), + ) + ) + + state = account._dividend_lifecycle_states[("account", value.dividend_id)] + assert state.declared_gross.to_decimal() == Decimal("3.00000000") + assert state.receivable_gross.to_decimal() == Decimal("2.70000000") + assert record.issuer_conversion_audit["relationship_status"] == ( + "unverified_no_rounding_contract" + ) + assert record.issuer_conversion_audit["account_published_minus_rate_implied"] == { + "numerator": "0", + "denominator": "1", + } + assert account.dividend_receivable_balance("CNY") == 0 + assert account.dividend_receivable_balance("HKD") == Decimal("2.70000000") + transaction = account.transactions[-1] + for code in ("CNY", "HKD"): + assert ( + sum( + posting.amount.units for posting in transaction.postings if posting.currency == code + ) + == 0 + ) + + +def test_issuer_conversion_preserves_unverified_rate_relationship_audit() -> None: + account = ledger() + initial = lifecycle(terms=entitlement(declared="CNY", options=("CNY", "HKD"))) + apply_entitlement(account, initial) + inconsistent = lifecycle( + terms=initial.entitlement, + choice=election("HKD"), + conversion=issuer_conversion(rate="0.8", published="0.90"), + policy=certified_policy(), + ) + + record = account.apply_dividend_lifecycle( + request( + inconsistent, + DividendExecutionPhase.ISSUER_CONVERSION, + cutoff=EX_AT + timedelta(days=1), + ) + ) + + assert record.issuer_conversion_audit == { + "relationship_status": "unverified_no_rounding_contract", + "normalized_source_unit_name": "share", + "declared_amount": initial.entitlement.approved_amount.to_dict(), + "rate_text": "0.8", + "published_payment_amount": inconsistent.conversion.published_payment_amount.to_dict(), + "declared_per_unit": {"numerator": "1", "denominator": "1"}, + "rate_implied_payment_per_unit": {"numerator": "4", "denominator": "5"}, + "published_payment_per_unit": {"numerator": "9", "denominator": "10"}, + "published_minus_rate_implied": {"numerator": "1", "denominator": "10"}, + "account_quantity": {"numerator": "3", "denominator": "1"}, + "rate_implied_account_payment": {"numerator": "12", "denominator": "5"}, + "published_account_payment": {"numerator": "27", "denominator": "10"}, + "account_published_minus_rate_implied": { + "numerator": "3", + "denominator": "10", + }, + } + + +def test_issuer_conversion_rejects_incomparable_source_units_atomically() -> None: + account = ledger() + initial = lifecycle(terms=entitlement(declared="CNY", options=("CNY", "HKD"))) + apply_entitlement(account, initial) + conversion = issuer_conversion() + conversion = replace( + conversion, + published_payment_amount=replace( + conversion.published_payment_amount, + source_unit_name="depositary receipt", + ), + ) + value = lifecycle( + terms=initial.entitlement, + choice=election("HKD"), + conversion=conversion, + policy=certified_policy(), + ) + before = account.capture_state() + + with pytest.raises(ValidationError, match="ISSUER_CONVERSION_SOURCE_UNIT_MISMATCH"): + account.apply_dividend_lifecycle( + request( + value, + DividendExecutionPhase.ISSUER_CONVERSION, + cutoff=EX_AT + timedelta(days=1), + ) + ) + + assert account.capture_state() == before + + +@pytest.mark.parametrize( + ("approved", "approved_units", "rate", "published", "published_units", "approximate"), + [ + ("0.10", "1", "7.776781", "0.777678", "1", True), + ("1.858", "10", "1.1014865663", "2.04656204", "10", False), + ], +) +def test_official_issuer_conversion_terms_do_not_invent_rounding_contract( + approved: str, + approved_units: str, + rate: str, + published: str, + published_units: str, + approximate: bool, +) -> None: + account = ledger() + initial = lifecycle( + terms=entitlement( + declared="CNY", + approved=approved, + source_units=approved_units, + options=("CNY", "HKD"), + ) + ) + apply_entitlement(account, initial) + value = lifecycle( + terms=initial.entitlement, + choice=election("HKD"), + conversion=issuer_conversion( + rate=rate, + published=published, + source_units=published_units, + approximate=approximate, + ), + policy=certified_policy(decimal_places=8), + ) + + record = account.apply_dividend_lifecycle( + request( + value, + DividendExecutionPhase.ISSUER_CONVERSION, + cutoff=EX_AT + timedelta(days=1), + ) + ) + + audit = record.issuer_conversion_audit + assert audit is not None + assert audit["relationship_status"] == "unverified_no_rounding_contract" + assert audit["rate_text"] == rate + assert audit["published_payment_amount"]["approximate"] is approximate + assert audit["published_minus_rate_implied"] != {"numerator": "0", "denominator": "1"} + + +def test_prefix_change_is_rejected_without_state_change() -> None: + account = ledger() + apply_entitlement(account) + before = account.capture_state() + changed = lifecycle( + terms=entitlement(approved="2.00"), + choice=election("HKD"), + policy=certified_policy(), + ) + + with pytest.raises(ValidationError, match="LIFECYCLE_PREFIX_CHANGED"): + account.apply_dividend_lifecycle( + request( + changed, + DividendExecutionPhase.ISSUER_CONVERSION, + cutoff=EX_AT + timedelta(days=1), + ) + ) + + assert account.capture_state() == before + + +def test_unknown_tax_keeps_gross_exposure_but_payment_is_rejected() -> None: + account = ledger() + apply_entitlement(account) + exposure = account.dividend_exposure(as_of=EX_AT) + assert exposure.tax_status == "unknown" + assert exposure.estimated_net_cash is None + before = account.capture_state() + with pytest.raises(ValueError, match="certified payment policy"): + lifecycle( + choice=election("HKD"), + paid=payment(gross="3", net="3"), + ) + + assert account.capture_state() == before + + +def test_certified_zero_tax_payment_uses_original_entitlement_after_position_changes() -> None: + account = ledger() + prefix = same_currency_prefix(account) + close = LedgerTransaction( + transaction_id="tx:close-position", + idempotency_key="close-position", + event_time=EX_AT + timedelta(days=1, hours=1), + event_type=LedgerEventType.SETTLEMENT, + reference_id="close-position", + postings=( + Posting( + ledger_account="assets:position", + currency="HKD", + amount=FixedPoint(0, 0), + instrument_id=STOCK, + quantity_delta=FixedPoint(-3, 0), + ), + Posting( + ledger_account="memo:position_counter", + currency="HKD", + amount=FixedPoint(0, 0), + instrument_id=STOCK, + quantity_delta=FixedPoint(3, 0), + ), + ), + ) + account._post(close) + account._event_time = close.event_time + paid = replace(prefix, payment=payment(gross="3", net="3")) + + account.apply_dividend_lifecycle( + request( + paid, + DividendExecutionPhase.PAYMENT, + cutoff=EX_AT + timedelta(days=2), + ) + ) + + assert account._positions[STOCK] == 0 + assert account.cash_balance("HKD") == Decimal("103.00000000") + assert account.dividend_receivable_balance("HKD") == 0 + + +def test_payment_posts_withholding_deductions_and_signed_rounding_exactly() -> None: + account = ledger() + prefix = same_currency_prefix(account) + fee_one = CashDeduction("fee-one", "0.10", "fee-evidence-1") + fee_two = CashDeduction("fee-two", "0.20", "fee-evidence-2") + paid = replace( + prefix, + payment=payment( + gross="3.00", + net="2.55", + withholding="0.20", + deductions=(fee_one, fee_two), + rounding="-0.05", + ), + ) + + account.apply_dividend_lifecycle( + request( + paid, + DividendExecutionPhase.PAYMENT, + cutoff=EX_AT + timedelta(days=2), + ) + ) + + assert account.cash_balance("HKD") == Decimal("102.55000000") + assert account._accounts[ + ("expenses:dividend_withholding", "HKD", "dividend:dividend-ordinary") + ] == Decimal("0.20000000") + assert account._accounts[ + ("expenses:dividend_rounding", "HKD", "dividend:dividend-ordinary") + ] == Decimal("-0.05000000") + + +@pytest.mark.parametrize( + ("policy", "money_scale", "message"), + [ + (None, 2, "ROUNDING_POLICY_REQUIRED"), + ( + certified_policy(scope="per_source_unit", at=EX_AT - timedelta(minutes=1)), + 8, + "UNSUPPORTED_ROUNDING_SCOPE", + ), + ( + certified_policy(decimal_places=9, at=EX_AT - timedelta(minutes=1)), + 8, + "LEDGER_SCALE_TOO_COARSE", + ), + ], +) +def test_rounding_rejections_are_explicit_and_atomic( + policy, money_scale: int, message: str +) -> None: + account = ledger(money_scale=money_scale) + terms = entitlement(approved="0.10", source_units="3") + evidence_basis = basis(quantity=FixedPoint(2, 0)) + account._positions[STOCK] = Decimal(2) + value = lifecycle(terms=terms, policy=policy) + before = account.capture_state() + + with pytest.raises(ValidationError, match=message): + apply_entitlement(account, value, evidence_basis) + + assert account.capture_state() == before + + +def test_pit_fx_missing_future_ambiguous_and_unique_selection() -> None: + account = ledger() + initial = lifecycle(terms=entitlement(declared="USD", options=("USD",))) + apply_entitlement(account, initial) + with pytest.raises(ValidationError, match="PIT_FX_UNAVAILABLE"): + account.snapshot(EX_AT) + + account.observe_pit_fx( + pit_rate( + "future", + base="USD", + rate="7.8", + observed_at=EX_AT + timedelta(hours=1), + ) + ) + with pytest.raises(ValidationError, match="PIT_FX_UNAVAILABLE"): + account.snapshot(EX_AT) + + observed = EX_AT - timedelta(minutes=1) + account.observe_pit_fx(pit_rate("a", base="USD", rate="7.8", observed_at=observed)) + account.observe_pit_fx(pit_rate("b", base="USD", rate="7.9", observed_at=observed)) + with pytest.raises(ValidationError, match="ambiguous"): + account.snapshot(EX_AT) + + unique = ledger() + apply_entitlement(unique, initial) + unique.observe_pit_fx(pit_rate("only", base="USD", rate="7.8", observed_at=observed)) + assert unique.snapshot(EX_AT).nav.to_decimal() == Decimal("153.40000000") + + +def test_evidenced_pit_rejects_legacy_fx_mutation() -> None: + account = ledger() + with pytest.raises(ValidationError, match="EVIDENCED_PIT_REJECTS_LEGACY_FX"): + account.set_fx_rate("USD", FixedPoint(78, 1), event_time=EX_AT) + + +def test_pit_fx_event_id_conflict_is_rejected_without_changing_observation_order() -> None: + account = ledger() + observed = EX_AT - timedelta(minutes=1) + account.observe_pit_fx(pit_rate("same", base="USD", rate="7.8", observed_at=observed)) + before = account.capture_state() + + with pytest.raises(ValidationError, match="PIT_FX_EVENT_ID_CONFLICT"): + account.observe_pit_fx(pit_rate("same", base="USD", rate="7.9", observed_at=observed)) + + assert account.capture_state() == before + + +def test_historical_fx_availability_does_not_create_historical_ledger_state() -> None: + account = ledger() + prefix = same_currency_prefix(account) + account.apply_dividend_lifecycle( + request( + replace(prefix, payment=payment(gross="3", net="3")), + DividendExecutionPhase.PAYMENT, + cutoff=EX_AT + timedelta(days=2), + ) + ) + + with pytest.raises(ValidationError, match="HISTORICAL_LEDGER_STATE_UNAVAILABLE"): + account.snapshot(EX_AT + timedelta(days=1)) + + +def test_usd_payment_keeps_evidenced_pit_across_cash_nav_risk_and_liquidation() -> None: + account = ledger() + initial = lifecycle(terms=entitlement(declared="USD", options=("USD",))) + apply_entitlement(account, initial) + observed = EX_AT - timedelta(minutes=1) + account.observe_pit_fx(pit_rate("usd-hkd", base="USD", rate="7.8", observed_at=observed)) + prefix = lifecycle( + terms=initial.entitlement, + choice=election("USD"), + policy=certified_policy(), + ) + account.apply_dividend_lifecycle( + request( + prefix, + DividendExecutionPhase.ISSUER_CONVERSION, + cutoff=EX_AT + timedelta(days=1), + ) + ) + paid = replace( + prefix, + payment=payment(gross="3", net="3", payment_currency="USD"), + ) + account.apply_dividend_lifecycle( + request( + paid, + DividendExecutionPhase.PAYMENT, + cutoff=EX_AT + timedelta(days=2), + ) + ) + at = EX_AT + timedelta(days=2) + + snapshot = account.snapshot(at) + _, _, risk_nav, _ = account.risk_balances(at) + portfolio = account.portfolio_risk_snapshot(at) + account.assert_nav_residual(snapshot) + + assert account.cash_balance("USD") == Decimal("3.00000000") + assert account.convert_to_base(Decimal(3), "USD", event_time=at) == Decimal("23.4") + assert snapshot.nav.to_decimal() == Decimal("153.40000000") + assert risk_nav == Decimal("153.400000000") + assert portfolio.nav == snapshot.nav + assert account.liquidation_required(at) is False + + +def test_payment_without_pit_fx_preserves_cash_but_all_valuation_paths_fail() -> None: + account = ledger() + initial = lifecycle(terms=entitlement(declared="USD", options=("USD",))) + apply_entitlement(account, initial) + prefix = lifecycle( + terms=initial.entitlement, + choice=election("USD"), + policy=certified_policy(), + ) + account.apply_dividend_lifecycle( + request( + prefix, + DividendExecutionPhase.ISSUER_CONVERSION, + cutoff=EX_AT + timedelta(days=1), + ) + ) + account.apply_dividend_lifecycle( + request( + replace( + prefix, + payment=payment(gross="3", net="3", payment_currency="USD"), + ), + DividendExecutionPhase.PAYMENT, + cutoff=EX_AT + timedelta(days=2), + ) + ) + at = EX_AT + timedelta(days=2) + assert account.cash_balance("USD") == Decimal("3.00000000") + + for query in ( + lambda: account.snapshot(at), + lambda: account.risk_balances(at), + lambda: account.portfolio_risk_snapshot(at), + lambda: account.liquidation_required(at), + lambda: account.convert_to_base(Decimal(3), "USD", event_time=at), + ): + with pytest.raises(ValidationError, match="PIT_FX_UNAVAILABLE"): + query() + + +def test_payment_preserves_economic_and_knowledge_time_separately() -> None: + account = ledger() + prefix = same_currency_prefix(account) + effective = EX_AT + timedelta(days=2) + available = EX_AT + timedelta(days=5) + late_payment = DividendPayment( + evidence=evidence( + "late-payment", + effective_at=effective, + available_at=available, + ), + account_id="account", + payment_currency="HKD", + policy_id="policy-1", + gross_cash_text="3", + withholding_cash_text="0", + deductions=(), + rounding_adjustment_text="0", + net_cash_text="3", + ) + record = account.apply_dividend_lifecycle( + request( + replace(prefix, payment=late_payment), + DividendExecutionPhase.PAYMENT, + cutoff=available, + ) + ) + + assert record.economic_effective_at == effective + assert record.available_at == available + assert record.applied_at == available + assert account._event_time == available + + +def test_payment_gross_and_accepted_election_conflicts_roll_back() -> None: + gross_account = ledger() + gross_prefix = same_currency_prefix(gross_account) + gross_before = gross_account.capture_state() + with pytest.raises(ValidationError, match="PAYMENT_GROSS_MISMATCH"): + gross_account.apply_dividend_lifecycle( + request( + replace(gross_prefix, payment=payment(gross="2", net="2")), + DividendExecutionPhase.PAYMENT, + cutoff=EX_AT + timedelta(days=2), + ) + ) + assert gross_account.capture_state() == gross_before + + policy_account = ledger() + same_currency_prefix(policy_account) + policy_before = policy_account.capture_state() + changed = lifecycle( + choice=election("HKD", policy_id="policy-2"), + policy=certified_policy(policy_id="policy-2"), + paid=payment(gross="3", net="3", policy_id="policy-2"), + ) + with pytest.raises(ValidationError, match="LIFECYCLE_PREFIX_CHANGED"): + policy_account.apply_dividend_lifecycle( + request( + changed, + DividendExecutionPhase.PAYMENT, + cutoff=EX_AT + timedelta(days=2), + ) + ) + assert policy_account.capture_state() == policy_before + + +def test_fixed_point_overflow_is_explicit_and_atomic() -> None: + account = ledger() + huge = lifecycle(terms=entitlement(approved="1000000000000.00")) + before = account.capture_state() + + with pytest.raises(ValidationError, match="FIXED_POINT_OVERFLOW"): + apply_entitlement(account, huge) + + assert account.capture_state() == before + + +def test_read_only_queries_do_not_change_records_state_or_journal() -> None: + account = ledger() + apply_entitlement(account) + before_state = account.capture_state() + before_hash = account.journal_sha256 + + account.snapshot(EX_AT) + account.dividend_exposure(as_of=EX_AT) + account.risk_balances(EX_AT) + + assert account.capture_state() == before_state + assert account.journal_sha256 == before_hash + + +def test_explicit_valuation_is_idempotent_and_conflicts_after_new_fx_fact() -> None: + account = ledger() + initial = lifecycle(terms=entitlement(declared="USD", options=("USD",))) + apply_entitlement(account, initial) + observed = EX_AT - timedelta(minutes=1) + account.observe_pit_fx(pit_rate("first", base="USD", rate="7.8", observed_at=observed)) + + first = account.record_dividend_valuation(as_of=EX_AT) + assert account.record_dividend_valuation(as_of=EX_AT) == first + account.observe_pit_fx(pit_rate("newer", base="USD", rate="7.9", observed_at=EX_AT)) + with pytest.raises(ValidationError, match="VALUATION_IDEMPOTENCY_CONFLICT"): + account.record_dividend_valuation(as_of=EX_AT) + + +@pytest.mark.parametrize("precision", [2, 6, 28, 80]) +def test_dividend_lifecycle_is_independent_of_decimal_context(precision: int) -> None: + with localcontext() as context: + context.prec = precision + context.traps[Inexact] = True + context.traps[Rounded] = True + account = ledger() + value = lifecycle(terms=entitlement(approved="0.10", source_units="3")) + apply_entitlement(account, value) + prefix = lifecycle( + terms=value.entitlement, + choice=election("HKD"), + policy=certified_policy(), + ) + account.apply_dividend_lifecycle( + request( + prefix, + DividendExecutionPhase.ISSUER_CONVERSION, + cutoff=EX_AT + timedelta(days=1), + ) + ) + account.apply_dividend_lifecycle( + request( + replace(prefix, payment=payment(gross="0.10", net="0.10")), + DividendExecutionPhase.PAYMENT, + cutoff=EX_AT + timedelta(days=2), + ) + ) + snapshot = account.snapshot(EX_AT + timedelta(days=2)) + risk = account.portfolio_risk_snapshot(EX_AT + timedelta(days=2)) + + assert account.dividend_receivable_balance("HKD") == 0 + assert account.cash_balance("HKD") == Decimal("100.10000000") + assert snapshot.nav.to_decimal() == Decimal("130.10000000") + assert risk.nav == snapshot.nav + + +def test_phase_failure_after_post_restores_complete_state(monkeypatch) -> None: + account = ledger() + before = account.capture_state() + original = account._post + + def fail_after_post(transaction, *, local_rollback=True): + original(transaction, local_rollback=local_rollback) + raise RuntimeError("injected phase failure") + + monkeypatch.setattr(account, "_post", fail_after_post) + with pytest.raises(RuntimeError, match="injected phase failure"): + apply_entitlement(account) + + assert account.capture_state() == before + + +def test_zero_entitlement_keeps_phase_identity_without_zero_transaction() -> None: + account = ledger() + account._positions[STOCK] = Decimal(0) + before = len(account.transactions) + zero_basis = basis(quantity=FixedPoint(0, 0)) + + record = apply_entitlement(account, evidence_basis=zero_basis) + + assert record.transaction_ids == () + assert len(account.transactions) == before + assert record.resulting_state_sha256 + assert account.journal_sha256 + + +def test_negative_and_derivative_positions_are_explicitly_unsupported() -> None: + negative = ledger() + negative._positions[STOCK] = Decimal(-3) + with pytest.raises(ValidationError, match="NEGATIVE_DIVIDEND_POSITION_UNSUPPORTED"): + apply_entitlement(negative) + + derivative_spec = spec( + STOCK, + asset_class=AssetClass.FUTURE, + product_type="future", + settlement_currency="HKD", + ) + derivative = ExactAccountLedger( + account_id="account", + base_currency="HKD", + instruments={STOCK: derivative_spec}, + opened_at=OPENED, + dividend_execution_mode=DividendExecutionMode.SCENARIO_ONLY, + fx_valuation_mode=FxValuationMode.EVIDENCED_PIT, + ) + derivative._positions[STOCK] = Decimal(3) + with pytest.raises(ValidationError, match="UNSUPPORTED_DERIVATIVE_DIVIDEND"): + apply_entitlement(derivative) diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 49beba5..5ec7168 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -10,6 +10,7 @@ from quant_data_kit.exceptions import ValidationError from quant_execution.contracts import Fee, Settlement +from quant_execution.dividends import DIVIDEND_RECORD_SCHEMA_ID, dividend_record_from_dict from quant_execution.schemas import ( FEE_SCHEMA_ID, FILL_SCHEMA_ID, @@ -172,3 +173,30 @@ def test_json_contract_rejects_illegal_transition_and_unbalanced_ledger() -> Non unbalanced["postings"][1]["amount"]["units"] = 9999 with pytest.raises(ValidationError, match="unbalanced"): validate_json_record(LEDGER_TRANSACTION_SCHEMA_ID, unbalanced) + + +def test_dividend_record_schema_round_trips_new_golden_without_changing_legacy_set() -> None: + path = Path(__file__).parent / "golden" / "v1_1" / "dividend_record.json" + golden = json.loads(path.read_text(encoding="utf-8")) + record = golden["record"] + + validate_json_record(DIVIDEND_RECORD_SCHEMA_ID, record, golden["schema_version"]) + with pytest.raises(ValidationError, match="Unknown execution schema ID"): + get_json_schema(DIVIDEND_RECORD_SCHEMA_ID, LEGACY_SCHEMA_VERSION) + with pytest.raises(ValidationError, match="dividend records require"): + execution_payload( + dividend_record_from_dict(record), + version=LEGACY_SCHEMA_VERSION, + ) + schema = get_arrow_schema(DIVIDEND_RECORD_SCHEMA_ID, SCHEMA_VERSION) + table = pa.Table.from_pylist( + [ + { + "schema": record["schema"], + "record_kind": record["record_kind"], + "payload": json.dumps(record, sort_keys=True, separators=(",", ":")), + } + ], + schema=schema, + ) + validate_arrow_table(DIVIDEND_RECORD_SCHEMA_ID, table, SCHEMA_VERSION)