From d1967ac5dc583fb640d0cfd0567585e349709d61 Mon Sep 17 00:00:00 2001 From: James Mallette Date: Sat, 22 Aug 2026 15:31:08 -0400 Subject: [PATCH] feat: expand corporate actions and instrument lifecycle --- CHANGELOG.md | 8 + README.md | 33 +- contracts/conformance/cases.json | 84 + contracts/conformance/manifest.json | 49 + contracts/strategy/v10/README.md | 59 + contracts/strategy/v10/dune | 15 + .../v10/fixtures/external.scenario.json | 302 +++ .../v10/fixtures/external.scenario.jsonl | 4 + .../v10/fixtures/external.strategy.jsonl | 14 + contracts/strategy/v10/message.schema.json | 302 +++ contracts/strategy/v10/transcript.schema.json | 82 + contracts/v12/README.md | 85 + contracts/v12/dune | 18 + contracts/v12/fixtures/demo.journal.jsonl | 31 + contracts/v12/fixtures/demo.scenario.json | 444 +++ contracts/v12/fixtures/demo.scenario.jsonl | 6 + .../v12/fixtures/fill-clipped.journal.jsonl | 13 + .../v12/fixtures/fill-clipped.scenario.json | 267 ++ contracts/v12/journal.schema.json | 2394 +++++++++++++++++ contracts/v12/scenario-stream.schema.json | 78 + contracts/v12/scenario.schema.json | 669 +++++ docs/api-reference.md | 2 +- docs/architecture.md | 3 +- docs/continuous-integration.md | 2 +- docs/execution-model.md | 12 +- docs/persistra.md | 6 +- docs/scenario.md | 30 +- lib/account.ml | 169 ++ lib/account.mli | 27 + lib/audit.ml | 16 + lib/audit.mli | 12 + lib/codec.ml | 146 +- lib/codec.mli | 1 + lib/contract.ml | 10 +- lib/corporate_action.ml | 73 + lib/corporate_action.mli | 26 + lib/engine.ml | 261 +- lib/engine.mli | 11 + lib/execution_model.ml | 2 +- lib/external_replay.ml | 11 +- lib/instrument_lifecycle.ml | 136 + lib/instrument_lifecycle.mli | 47 + lib/market_slice.ml | 29 +- lib/market_slice.mli | 16 + lib/replay.ml | 10 +- lib/scenario.ml | 242 +- lib/scenario_shape.ml | 36 +- lib/scenario_validation.ml | 58 +- lib/strategy_protocol.ml | 30 +- mkdocs.yml | 4 +- scripts/check-deterministic-journals | 8 + scripts/check-documentation.py | 4 +- scripts/release_artifacts.py | 12 +- test/cli.t | 2 +- test/dune | 73 + test/test_corporate_lifecycle.ml | 507 ++++ test/test_diagnostic.ml | 2 +- test/test_engine.ml | 1 + test/test_scenario.ml | 260 +- test/test_strategy_protocol.ml | 4 +- 60 files changed, 7117 insertions(+), 141 deletions(-) create mode 100644 contracts/strategy/v10/README.md create mode 100644 contracts/strategy/v10/dune create mode 100644 contracts/strategy/v10/fixtures/external.scenario.json create mode 100644 contracts/strategy/v10/fixtures/external.scenario.jsonl create mode 100644 contracts/strategy/v10/fixtures/external.strategy.jsonl create mode 100644 contracts/strategy/v10/message.schema.json create mode 100644 contracts/strategy/v10/transcript.schema.json create mode 100644 contracts/v12/README.md create mode 100644 contracts/v12/dune create mode 100644 contracts/v12/fixtures/demo.journal.jsonl create mode 100644 contracts/v12/fixtures/demo.scenario.json create mode 100644 contracts/v12/fixtures/demo.scenario.jsonl create mode 100644 contracts/v12/fixtures/fill-clipped.journal.jsonl create mode 100644 contracts/v12/fixtures/fill-clipped.scenario.json create mode 100644 contracts/v12/journal.schema.json create mode 100644 contracts/v12/scenario-stream.schema.json create mode 100644 contracts/v12/scenario.schema.json create mode 100644 lib/instrument_lifecycle.ml create mode 100644 lib/instrument_lifecycle.mli create mode 100644 test/test_corporate_lifecycle.ml diff --git a/CHANGELOG.md b/CHANGELOG.md index dc311aa..d64a099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- Add exact stock-dividend, rights, and spin-off distributions with explicit basis allocation, + fractional rejection or cash-in-lieu policy, destination currency validation, target adjustment, + and complete journal attribution. +- Add stable-identity instrument lifecycle state for halt, resume, identifier/provider remapping, + expiration, and delisting with deterministic order cancellation and explicit terminal hold or + cash-out policy. +- Publish scenario/journal contract v12 and external strategy protocol v10 while preserving v11 + and protocol v9 as frozen compatibility contracts. - Add deterministic trade-date and settlement-date accounting, versioned business-date settlement calendars, settled and unsettled cash and position attribution, explicit settlement buying-power policies, and auditable settlement completion and failure events. diff --git a/README.md b/README.md index 692ca38..50dbf26 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,8 @@ scenario slices and scheduled or external intents maker/taker applicability, minimums, caps, rebates, and deterministic FX conversion - Explicit multi-currency cash ledgers and complete per-slice FX marks in a base currency - Explicit signed initial portfolios with cost basis, P&L and fee history, marks, and FX state -- Split and cash-dividend processing before matching, including target and order adjustment +- Splits, dividends, rights, spin-offs, fractional cash-in-lieu, and exact basis allocation +- Stable instrument identity with halt/resume, identifier changes, expiration, and delisting - Effective-time short locates, availability clipping, borrow-rate accrual, recalls, and deterministic close-out orders - Per-currency credit/debit cash rates with explicit day-count and compounding policies @@ -60,7 +61,7 @@ scenario slices and scheduled or external intents fee-component attribution - Deterministic event IDs, ordered causal references, and order-creation attribution - Contract-selected compiled execution modules with versioned model-owned configuration and - capability descriptors; v11 currently exposes `completed_bar_v1` configuration v2 + capability descriptors; v12 currently exposes `completed_bar_v1` configuration v2 - Strict batch JSON and bounded-memory JSON Lines scenario parsing with JSON Schemas - Versioned synchronous JSON Lines strategy processes with per-request timeouts and strict lifecycle supervision @@ -90,7 +91,7 @@ Validate the included scenario with an in-memory replay: ```sh opam exec -- dune exec trading-engine -- \ - --input contracts/v11/fixtures/demo.scenario.json \ + --input contracts/v12/fixtures/demo.scenario.json \ --validate-only ``` @@ -98,7 +99,7 @@ Run it and create a journal: ```sh opam exec -- dune exec trading-engine -- \ - --input contracts/v11/fixtures/demo.scenario.json \ + --input contracts/v12/fixtures/demo.scenario.json \ --journal demo.journal.jsonl ``` @@ -106,7 +107,7 @@ For larger histories, validate and replay the equivalent stream one slice at a t ```sh opam exec -- dune exec trading-engine -- \ - --input contracts/v11/fixtures/demo.scenario.jsonl \ + --input contracts/v12/fixtures/demo.scenario.jsonl \ --input-format jsonl \ --journal demo.journal.jsonl ``` @@ -115,7 +116,7 @@ Run an external strategy against an empty-schedule scenario: ```sh opam exec -- dune exec trading-engine -- \ - --input contracts/strategy/v9/fixtures/external.scenario.json \ + --input contracts/strategy/v10/fixtures/external.scenario.json \ --journal external.journal.jsonl \ --strategy-executable ./my-strategy \ --strategy-arg=config.toml \ @@ -184,7 +185,11 @@ slice whose start is not earlier than its creation time. - Eligible liquidation orders consume capacity before other orders. Within each origin class, sells precede buys and FIFO creation order breaks ties within a side. - Corporate actions are applied before matching. Splits adjust positions, persistent targets, and - active orders; cash dividends credit longs and debit shorts in the quote-currency ledger. + active orders; distributions allocate basis and fractional cash exactly; cash dividends credit + longs and debit shorts in the quote-currency ledger. +- Lifecycle events update symbols and provider mappings without changing instrument identity. + Halts and terminal events cancel orders; expiration and delisting follow an explicit hold or + cash-out policy. - Effective-time borrow observations control short availability and rates. New shorts are rejected or clipped to their locate, recalls reject new shorts or create deterministic close-out orders, and observed borrow charges accrue before matching. @@ -226,19 +231,19 @@ do not provide reducer snapshots or restart recovery. - [Diagnostic contract](docs/diagnostics.md) - [Scenario contract](docs/scenario.md) - [Contract conformance corpus](contracts/conformance/README.md) -- [Current contract v11 and conformance fixtures](contracts/v11/README.md) +- [Current contract v12 and conformance fixtures](contracts/v12/README.md) - [Frozen contract v2](contracts/v2/README.md) - [Historical contract v1](contracts/v1/README.md) -- [Scenario JSON Schema](contracts/v11/scenario.schema.json) -- [Scenario stream record JSON Schema](contracts/v11/scenario-stream.schema.json) -- [Journal record JSON Schema](contracts/v11/journal.schema.json) -- [External strategy protocol v9](contracts/strategy/v9/README.md) +- [Scenario JSON Schema](contracts/v12/scenario.schema.json) +- [Scenario stream record JSON Schema](contracts/v12/scenario-stream.schema.json) +- [Journal record JSON Schema](contracts/v12/journal.schema.json) +- [External strategy protocol v10](contracts/strategy/v10/README.md) - [Historical strategy protocol v3](contracts/strategy/v3/README.md) - [Historical strategy protocol v2](contracts/strategy/v2/README.md) - [Historical strategy protocol v1](contracts/strategy/v1/README.md) - [Persistra compatibility](docs/persistra.md) -- [Strategy message JSON Schema](contracts/strategy/v9/message.schema.json) -- [Strategy transcript JSON Schema](contracts/strategy/v9/transcript.schema.json) +- [Strategy message JSON Schema](contracts/strategy/v10/message.schema.json) +- [Strategy transcript JSON Schema](contracts/strategy/v10/transcript.schema.json) - [Execution model](docs/execution-model.md) - [OCaml coverage](docs/coverage.md) - [Continuous integration and portability matrix](docs/continuous-integration.md) diff --git a/contracts/conformance/cases.json b/contracts/conformance/cases.json index 4e603e2..c36acb5 100644 --- a/contracts/conformance/cases.json +++ b/contracts/conformance/cases.json @@ -1190,6 +1190,90 @@ "mutations": [], "schema_expectation": "accept", "source": "strategy/v9/fixtures/external.strategy.jsonl" + }, + { + "name": "scenario-v12-valid", + "artifact": "scenario-v12", + "kind": "scenario", + "source": "v12/fixtures/demo.scenario.json", + "mutations": [], + "schema_expectation": "accept", + "parser_expectation": "accept" + }, + { + "name": "scenario-stream-v12-valid", + "artifact": "scenario-stream-v12", + "kind": "scenario_stream", + "source": "v12/fixtures/demo.scenario.jsonl", + "mutations": [], + "schema_expectation": "accept", + "parser_expectation": "accept" + }, + { + "name": "strategy-ready-valid-v10", + "artifact": "strategy-message-v10", + "instance": { + "strategy_protocol_version": "10", + "strategy_sequence": "1", + "message_type": "ready", + "payload": { "strategy_name": "conformance", "strategy_version": null } + }, + "mutations": [], + "schema_expectation": "accept", + "parser_expectation": "accept", + "parser_expected": "ready" + }, + { + "name": "strategy-intents-valid-v10", + "artifact": "strategy-message-v10", + "instance": { + "strategy_protocol_version": "10", + "strategy_sequence": "2", + "message_type": "intents", + "payload": { "intents": [] } + }, + "mutations": [], + "schema_expectation": "accept", + "parser_expectation": "accept", + "parser_expected": "intents" + }, + { + "name": "strategy-error-valid-v10", + "artifact": "strategy-message-v10", + "instance": { + "strategy_protocol_version": "10", + "strategy_sequence": "7", + "message_type": "error", + "payload": { "message": "fixture failure" } + }, + "mutations": [], + "schema_expectation": "accept" + }, + { + "name": "strategy-v10-rejected-response-branch", + "artifact": "strategy-transcript-v10", + "instance": { + "strategy_diagnostic_version": "1", + "transcript_sequence": "2", + "record_type": "rejected_strategy_response", + "expected_strategy_sequence": "1", + "diagnostic": { + "diagnostic_version": "1", + "code": "strategy.protocol", + "phase": "strategy", + "message": "strategy initialization: invalid strategy response JSON", + "context": { "json_path": "$", "sequence": "1" }, + "cause": null + }, + "evidence": { + "encoding": "hex", + "prefix": "7b", + "observed_bytes": 1, + "truncated": false + } + }, + "mutations": [], + "schema_expectation": "accept" } ] } diff --git a/contracts/conformance/manifest.json b/contracts/conformance/manifest.json index 59f555a..e61644d 100644 --- a/contracts/conformance/manifest.json +++ b/contracts/conformance/manifest.json @@ -757,6 +757,55 @@ "sources": [ { "path": "strategy/v9/fixtures/external.strategy.jsonl", "format": "jsonl" } ] + }, + { + "name": "scenario-v12", + "schema": "v12/scenario.schema.json", + "version_field": "contract_version", + "version": "12", + "sources": [ + { "path": "v12/fixtures/demo.scenario.json", "format": "json" }, + { "path": "v12/fixtures/fill-clipped.scenario.json", "format": "json" }, + { "path": "strategy/v10/fixtures/external.scenario.json", "format": "json" } + ] + }, + { + "name": "scenario-stream-v12", + "schema": "v12/scenario-stream.schema.json", + "version_field": "contract_version", + "version": "12", + "sources": [ + { "path": "v12/fixtures/demo.scenario.jsonl", "format": "jsonl" }, + { "path": "strategy/v10/fixtures/external.scenario.jsonl", "format": "jsonl" } + ] + }, + { + "name": "journal-v12", + "schema": "v12/journal.schema.json", + "version_field": "contract_version", + "version": "12", + "sources": [ + { "path": "v12/fixtures/demo.journal.jsonl", "format": "jsonl" }, + { "path": "v12/fixtures/fill-clipped.journal.jsonl", "format": "jsonl" } + ] + }, + { + "name": "strategy-message-v10", + "schema": "strategy/v10/message.schema.json", + "version_field": "strategy_protocol_version", + "version": "10", + "sources": [ + { "path": "strategy/v10/fixtures/external.strategy.jsonl", "format": "jsonl", "extract": ["message"] } + ] + }, + { + "name": "strategy-transcript-v10", + "schema": "strategy/v10/transcript.schema.json", + "version_field": "strategy_protocol_version", + "version": "10", + "sources": [ + { "path": "strategy/v10/fixtures/external.strategy.jsonl", "format": "jsonl" } + ] } ] } diff --git a/contracts/strategy/v10/README.md b/contracts/strategy/v10/README.md new file mode 100644 index 0000000..17c89a1 --- /dev/null +++ b/contracts/strategy/v10/README.md @@ -0,0 +1,59 @@ +# External strategy protocol v10 + +Version 10 is a synchronous JSON Lines protocol over child-process standard input and output. +Trading Engine sends `initialize`, ordered `event` requests, and `shutdown`. The strategy answers +with `ready`, `intents`, and `stopped`. It may answer any request with `error`. +Protocol v9 remains available for scenario contract v11; earlier versions retain their frozen +shapes. + +Every message repeats `strategy_protocol_version: "10"` and a positive canonical +`strategy_sequence`. A response must repeat the sequence of its request. Only one request is +outstanding. Trading Engine rejects unknown or duplicate fields, invalid canonical values, +oversized lines, a wrong version or sequence, unexpected response types, EOF, timeout, and a +nonzero process exit. + +The event context contains the replay clock, a marked base-currency portfolio, deterministic group +exposure snapshots, all working orders, and the latest available bar for each instrument. Every +callback emitted for a market slice uses +that slice's `received_at` as `now` and uses its complete bars and FX vector. The portfolio reports +cash, equity, net, long, short, and gross market value plus every attributed cash ledger and +configured position. Position quantities and weights reflect applied fills. Weights are truncated +toward zero to six decimal places. `weights_available` is false and all weights are null when +equity is zero or negative. + +The `initialize` request identifies scenario contract v12 and includes the exact `initial_portfolio` +snapshot alongside the legacy cash projection. It also carries the complete versioned venue +calendars, nested execution configuration, financing policy, and settlement policy, so a strategy +can construct DAY orders and reject incompatible execution, financing, or settlement state before +replay. + +Matching pauses after each strategy callback. The engine applies the response against the exact +account and OMS state exposed by that callback before delivering another callback or considering +the next eligible order. Later same-slice contexts include the effects of earlier responses. The +eligible-order sequence is fixed at the start of matching, so newly submitted orders wait for a +later slice. Cancelling an order before its turn leaves its unused slice capacity available to the +next eligible order. + +Event payloads cover completed market slices with effective-time borrow and cash-rate observations +plus explicit settlement failures, fills, order updates, and rejected intents. Portfolio contexts +include cash-interest attribution and settled and unsettled cash and position quantities. Response +intents use the scenario v12 intent shapes. Market-slice events include lifecycle transitions and +the expanded corporate-action catalog. + +External replay requires an empty batch schedule and empty streamed intent batches. The engine +records accepted messages in both directions in a deterministic transcript. A response rejected +for invalid JSON, fields, version, sequence, EOF, or size is never stored as an accepted exchange. +Instead, the partial transcript ends with a `rejected_strategy_response` diagnostic record. Version +1 rejection diagnostics use the shared +[`diagnostic/v1`](../../diagnostic/v1/README.md) contract. The transcript schema narrows that +contract to the `strategy.protocol` and `resource.limit` codes in the `strategy` phase. The record +includes the structured rejection diagnostic and at most the first 256 raw response bytes encoded +as lowercase hexadecimal. `observed_bytes` counts bytes available when the engine rejected the +response, and `truncated` reports whether the prefix omits observed bytes. The transcript and audit +journal retain partial files after failure and finalize only after their respective success checks. + +- `message.schema.json` validates individual requests and responses. +- `transcript.schema.json` validates accepted exchanges and rejected-response diagnostics. +- `fixtures/external.scenario.json` is the batch replay fixture. +- `fixtures/external.scenario.jsonl` is its bounded-memory stream form. +- `fixtures/external.strategy.jsonl` is the canonical protocol transcript. diff --git a/contracts/strategy/v10/dune b/contracts/strategy/v10/dune new file mode 100644 index 0000000..7fa7432 --- /dev/null +++ b/contracts/strategy/v10/dune @@ -0,0 +1,15 @@ +(install + (section share) + (package trading_engine) + (files + (message.schema.json as contracts/strategy/v10/message.schema.json) + (transcript.schema.json as contracts/strategy/v10/transcript.schema.json) + (fixtures/external.scenario.json + as + contracts/strategy/v10/fixtures/external.scenario.json) + (fixtures/external.scenario.jsonl + as + contracts/strategy/v10/fixtures/external.scenario.jsonl) + (fixtures/external.strategy.jsonl + as + contracts/strategy/v10/fixtures/external.strategy.jsonl))) diff --git a/contracts/strategy/v10/fixtures/external.scenario.json b/contracts/strategy/v10/fixtures/external.scenario.json new file mode 100644 index 0000000..c37e23b --- /dev/null +++ b/contracts/strategy/v10/fixtures/external.scenario.json @@ -0,0 +1,302 @@ +{ + "contract_version": "12", + "metadata": { + "producer": "strategy-protocol-fixture" + }, + "run_id": "external-demo", + "base_currency": "USD", + "initial_portfolio": { + "cash": [ + { + "currency": "USD", + "amount": "10000" + } + ], + "positions": [], + "marks": [], + "fx_rates": [ + { + "currency": "USD", + "rate": "1" + } + ] + }, + "instruments": [ + { + "instrument_id": "demo-equity-acme", + "symbol": "ACME", + "quote_currency": "USD", + "tick_size": "0.01", + "lot_size": "1" + } + ], + "venue_calendars": [ + { + "calendar_id": "demo-xnas-2026", + "calendar_version": "1", + "venue_id": "XNAS", + "instrument_ids": [ + "demo-equity-acme" + ], + "sessions": [ + { + "session_date": "2026-01-01", + "policy": "holiday", + "phases": [] + }, + { + "session_date": "2026-01-02", + "policy": "regular", + "phases": [ + { + "phase": "premarket", + "opens_at": "2026-01-02T09:00:00Z", + "closes_at": "2026-01-02T14:25:00Z" + }, + { + "phase": "opening_auction", + "opens_at": "2026-01-02T14:25:00Z", + "closes_at": "2026-01-02T14:30:00Z" + }, + { + "phase": "regular", + "opens_at": "2026-01-02T14:30:00Z", + "closes_at": "2026-01-02T20:55:00Z" + }, + { + "phase": "closing_auction", + "opens_at": "2026-01-02T20:55:00Z", + "closes_at": "2026-01-02T21:00:00Z" + }, + { + "phase": "postmarket", + "opens_at": "2026-01-02T21:00:00Z", + "closes_at": "2026-01-03T01:00:00Z" + } + ] + }, + { + "session_date": "2026-01-05", + "policy": "regular", + "phases": [ + { + "phase": "regular", + "opens_at": "2026-01-05T14:30:00Z", + "closes_at": "2026-01-05T21:00:00Z" + } + ] + }, + { + "session_date": "2026-01-06", + "policy": "regular", + "phases": [ + { + "phase": "regular", + "opens_at": "2026-01-06T14:30:00Z", + "closes_at": "2026-01-06T21:00:00Z" + } + ] + }, + { + "session_date": "2026-01-07", + "policy": "regular", + "phases": [ + { + "phase": "regular", + "opens_at": "2026-01-07T14:30:00Z", + "closes_at": "2026-01-07T21:00:00Z" + } + ] + }, + { + "session_date": "2026-01-08", + "policy": "early_close", + "phases": [ + { + "phase": "regular", + "opens_at": "2026-01-08T14:30:00Z", + "closes_at": "2026-01-08T18:00:00Z" + } + ] + } + ] + } + ], + "risk": { + "max_gross_exposure": "1000000", + "max_leverage": "2", + "short_borrow_bps": 0, + "instrument_policies": [ + { + "instrument_id": "demo-equity-acme", + "max_order_quantity": "1000", + "max_long_position": "1000", + "max_short_position": "1000", + "max_notional_exposure": "1000000", + "initial_margin_bps": 5000, + "maintenance_margin_bps": 2500, + "shorting_allowed": true + } + ], + "groups": [] + }, + "execution": { + "model": "completed_bar_v1", + "configuration": { + "version": "2", + "participation_bps": 5000, + "fee_schedules": [ + { + "schedule_id": "external-acme-fees-v1", + "instrument_id": "demo-equity-acme", + "settlement_currency": "USD", + "minimum": null, + "maximum": null, + "components": [ + { + "name": "broker", + "currency": "USD", + "kind": "fixed", + "value": "0.25", + "rounding": "up", + "applies_to": "any" + }, + { + "name": "exchange", + "currency": "USD", + "kind": "notional_bps", + "value": 10, + "rounding": "up", + "applies_to": "any" + } + ] + } + ] + } + }, + "max_internal_events": 1000, + "schedule": [], + "slices": [ + { + "slice_sequence": "1", + "start_at": "2026-01-02T14:30:00Z", + "end_at": "2026-01-02T21:00:00Z", + "available_at": "2026-01-02T21:00:01Z", + "received_at": "2026-01-02T21:00:02Z", + "bars": [ + { + "instrument_id": "demo-equity-acme", + "open": "100", + "high": "105", + "low": "99", + "close": "104", + "volume": "100" + } + ], + "fx_rates": [ + { + "currency": "USD", + "rate": "1" + } + ], + "corporate_actions": [], + "borrow_observations": [ + { + "instrument_id": "demo-equity-acme", + "effective_at": "2026-01-02T14:30:00Z", + "available_quantity": "1000", + "annual_rate_bps": 100, + "recalled": false + } + ], + "cash_rate_observations": [ + { + "currency": "USD", + "effective_at": "2026-01-02T14:30:00Z", + "credit_rate_bps": 0, + "debit_rate_bps": 0 + } + ], + "settlement_failures": [], "lifecycle_events": [] + }, + { + "slice_sequence": "2", + "start_at": "2026-01-05T14:30:00Z", + "end_at": "2026-01-05T21:00:00Z", + "available_at": "2026-01-05T21:00:01Z", + "received_at": "2026-01-05T21:00:02Z", + "bars": [ + { + "instrument_id": "demo-equity-acme", + "open": "103", + "high": "108", + "low": "102", + "close": "107", + "volume": "100" + } + ], + "fx_rates": [ + { + "currency": "USD", + "rate": "1" + } + ], + "corporate_actions": [], + "borrow_observations": [ + { + "instrument_id": "demo-equity-acme", + "effective_at": "2026-01-05T14:30:00Z", + "available_quantity": "1000", + "annual_rate_bps": 100, + "recalled": false + } + ], + "cash_rate_observations": [ + { + "currency": "USD", + "effective_at": "2026-01-05T14:30:00Z", + "credit_rate_bps": 0, + "debit_rate_bps": 0 + } + ], + "settlement_failures": [], "lifecycle_events": [] + } + ], + "financing": { + "day_count": "actual_365", + "compounding": "simple", + "borrow_missing_data": "reject", + "cash_missing_data": "reject", + "locate_policy": "clip_fill", + "recall_policy": "close_out" + }, + "settlement": { + "cash_buying_power": "total_cash", + "position_availability": "total_positions", + "calendars": [ + { + "calendar_id": "default-settlement", + "version": "1", + "business_dates": [ + "2026-01-02", + "2026-01-05", + "2026-01-06", + "2026-01-07", + "2026-01-08", + "2026-01-09", + "2026-02-02", + "2026-02-03", + "2026-02-04", + "2026-02-05" + ] + } + ], + "rules": [ + { + "instrument_id": "demo-equity-acme", + "calendar_id": "default-settlement", + "lag_business_days": 1 + } + ] + } +} diff --git a/contracts/strategy/v10/fixtures/external.scenario.jsonl b/contracts/strategy/v10/fixtures/external.scenario.jsonl new file mode 100644 index 0000000..e428012 --- /dev/null +++ b/contracts/strategy/v10/fixtures/external.scenario.jsonl @@ -0,0 +1,4 @@ +{"contract_version":"12","scenario_sequence":"1","record_type":"scenario_header","payload":{"metadata":{"producer":"strategy-protocol-fixture"},"run_id":"external-demo","base_currency":"USD","initial_portfolio":{"cash":[{"currency":"USD","amount":"10000"}],"positions":[],"marks":[],"fx_rates":[{"currency":"USD","rate":"1"}]},"instruments":[{"instrument_id":"demo-equity-acme","symbol":"ACME","quote_currency":"USD","tick_size":"0.01","lot_size":"1"}],"venue_calendars":[{"calendar_id":"demo-xnas-2026","calendar_version":"1","venue_id":"XNAS","instrument_ids":["demo-equity-acme"],"sessions":[{"session_date":"2026-01-01","policy":"holiday","phases":[]},{"session_date":"2026-01-02","policy":"regular","phases":[{"phase":"premarket","opens_at":"2026-01-02T09:00:00Z","closes_at":"2026-01-02T14:25:00Z"},{"phase":"opening_auction","opens_at":"2026-01-02T14:25:00Z","closes_at":"2026-01-02T14:30:00Z"},{"phase":"regular","opens_at":"2026-01-02T14:30:00Z","closes_at":"2026-01-02T20:55:00Z"},{"phase":"closing_auction","opens_at":"2026-01-02T20:55:00Z","closes_at":"2026-01-02T21:00:00Z"},{"phase":"postmarket","opens_at":"2026-01-02T21:00:00Z","closes_at":"2026-01-03T01:00:00Z"}]},{"session_date":"2026-01-05","policy":"regular","phases":[{"phase":"regular","opens_at":"2026-01-05T14:30:00Z","closes_at":"2026-01-05T21:00:00Z"}]},{"session_date":"2026-01-06","policy":"regular","phases":[{"phase":"regular","opens_at":"2026-01-06T14:30:00Z","closes_at":"2026-01-06T21:00:00Z"}]},{"session_date":"2026-01-07","policy":"regular","phases":[{"phase":"regular","opens_at":"2026-01-07T14:30:00Z","closes_at":"2026-01-07T21:00:00Z"}]},{"session_date":"2026-01-08","policy":"early_close","phases":[{"phase":"regular","opens_at":"2026-01-08T14:30:00Z","closes_at":"2026-01-08T18:00:00Z"}]}]}],"risk":{"max_gross_exposure":"1000000","max_leverage":"2","short_borrow_bps":0,"instrument_policies":[{"instrument_id":"demo-equity-acme","max_order_quantity":"1000","max_long_position":"1000","max_short_position":"1000","max_notional_exposure":"1000000","initial_margin_bps":5000,"maintenance_margin_bps":2500,"shorting_allowed":true}],"groups":[]},"execution":{"model":"completed_bar_v1","configuration":{"version":"2","participation_bps":5000,"fee_schedules":[{"schedule_id":"external-acme-fees-v1","instrument_id":"demo-equity-acme","settlement_currency":"USD","minimum":null,"maximum":null,"components":[{"name":"broker","currency":"USD","kind":"fixed","value":"0.25","rounding":"up","applies_to":"any"},{"name":"exchange","currency":"USD","kind":"notional_bps","value":10,"rounding":"up","applies_to":"any"}]}]}},"max_internal_events":1000,"financing":{"day_count":"actual_365","compounding":"simple","borrow_missing_data":"reject","cash_missing_data":"reject","locate_policy":"clip_fill","recall_policy":"close_out"},"settlement":{"cash_buying_power":"total_cash","position_availability":"total_positions","calendars":[{"calendar_id":"default-settlement","version":"1","business_dates":["2026-01-02","2026-01-05","2026-01-06","2026-01-07","2026-01-08","2026-01-09","2026-02-02","2026-02-03","2026-02-04","2026-02-05"]}],"rules":[{"instrument_id":"demo-equity-acme","calendar_id":"default-settlement","lag_business_days":1}]}}} +{"contract_version":"12","payload":{"intents":[],"market_slice":{"available_at":"2026-01-02T21:00:01Z","bars":[{"close":"104","high":"105","instrument_id":"demo-equity-acme","low":"99","open":"100","volume":"100"}],"corporate_actions":[],"end_at":"2026-01-02T21:00:00Z","fx_rates":[{"currency":"USD","rate":"1"}],"received_at":"2026-01-02T21:00:02Z","slice_sequence":"1","start_at":"2026-01-02T14:30:00Z","borrow_observations":[{"instrument_id":"demo-equity-acme","effective_at":"2026-01-02T14:30:00Z","available_quantity":"1000","annual_rate_bps":100,"recalled":false}],"cash_rate_observations":[{"currency":"USD","effective_at":"2026-01-02T14:30:00Z","credit_rate_bps":0,"debit_rate_bps":0}],"settlement_failures":[],"lifecycle_events":[]}},"record_type":"market_slice","scenario_sequence":"2"} +{"contract_version":"12","payload":{"intents":[],"market_slice":{"available_at":"2026-01-05T21:00:01Z","bars":[{"close":"107","high":"108","instrument_id":"demo-equity-acme","low":"102","open":"103","volume":"100"}],"corporate_actions":[],"end_at":"2026-01-05T21:00:00Z","fx_rates":[{"currency":"USD","rate":"1"}],"received_at":"2026-01-05T21:00:02Z","slice_sequence":"2","start_at":"2026-01-05T14:30:00Z","borrow_observations":[{"instrument_id":"demo-equity-acme","effective_at":"2026-01-05T14:30:00Z","available_quantity":"1000","annual_rate_bps":100,"recalled":false}],"cash_rate_observations":[{"currency":"USD","effective_at":"2026-01-05T14:30:00Z","credit_rate_bps":0,"debit_rate_bps":0}],"settlement_failures":[],"lifecycle_events":[]}},"record_type":"market_slice","scenario_sequence":"3"} +{"contract_version":"12","payload":{"slice_count":"2"},"record_type":"scenario_end","scenario_sequence":"4"} diff --git a/contracts/strategy/v10/fixtures/external.strategy.jsonl b/contracts/strategy/v10/fixtures/external.strategy.jsonl new file mode 100644 index 0000000..0b07034 --- /dev/null +++ b/contracts/strategy/v10/fixtures/external.strategy.jsonl @@ -0,0 +1,14 @@ +{"strategy_protocol_version":"10","transcript_sequence":"1","direction":"engine_to_strategy","message":{"strategy_protocol_version":"10","strategy_sequence":"1","message_type":"initialize","payload":{"engine_version":"1.0.0","scenario_contract_version":"12","scenario_sha256":"6809a3638fe668a2a11e56c42e9bac7e506c0064eb2cb0a3e71ba09d92839ee7","run_id":"external-demo","base_currency":"USD","initial_cash":[{"currency":"USD","amount":"10000"}],"initial_portfolio":{"cash":[{"currency":"USD","amount":"10000"}],"positions":[],"marks":[],"fx_rates":[{"currency":"USD","rate":"1"}]},"venue_calendars":[{"calendar_id":"demo-xnas-2026","calendar_version":"1","venue_id":"XNAS","instrument_ids":["demo-equity-acme"],"sessions":[{"session_date":"2026-01-01","policy":"holiday","phases":[]},{"session_date":"2026-01-02","policy":"regular","phases":[{"phase":"premarket","opens_at":"2026-01-02T09:00:00.000000Z","closes_at":"2026-01-02T14:25:00.000000Z"},{"phase":"opening_auction","opens_at":"2026-01-02T14:25:00.000000Z","closes_at":"2026-01-02T14:30:00.000000Z"},{"phase":"regular","opens_at":"2026-01-02T14:30:00.000000Z","closes_at":"2026-01-02T20:55:00.000000Z"},{"phase":"closing_auction","opens_at":"2026-01-02T20:55:00.000000Z","closes_at":"2026-01-02T21:00:00.000000Z"},{"phase":"postmarket","opens_at":"2026-01-02T21:00:00.000000Z","closes_at":"2026-01-03T01:00:00.000000Z"}]},{"session_date":"2026-01-05","policy":"regular","phases":[{"phase":"regular","opens_at":"2026-01-05T14:30:00.000000Z","closes_at":"2026-01-05T21:00:00.000000Z"}]},{"session_date":"2026-01-06","policy":"regular","phases":[{"phase":"regular","opens_at":"2026-01-06T14:30:00.000000Z","closes_at":"2026-01-06T21:00:00.000000Z"}]},{"session_date":"2026-01-07","policy":"regular","phases":[{"phase":"regular","opens_at":"2026-01-07T14:30:00.000000Z","closes_at":"2026-01-07T21:00:00.000000Z"}]},{"session_date":"2026-01-08","policy":"early_close","phases":[{"phase":"regular","opens_at":"2026-01-08T14:30:00.000000Z","closes_at":"2026-01-08T18:00:00.000000Z"}]}]}],"financing":{"day_count":"actual_365","compounding":"simple","borrow_missing_data":"reject","cash_missing_data":"reject","locate_policy":"clip_fill","recall_policy":"close_out"},"settlement":{"cash_buying_power":"total_cash","position_availability":"total_positions","calendars":[{"calendar_id":"default-settlement","version":"1","business_dates":["2026-01-02","2026-01-05","2026-01-06","2026-01-07","2026-01-08","2026-01-09","2026-02-02","2026-02-03","2026-02-04","2026-02-05"]}],"rules":[{"instrument_id":"demo-equity-acme","calendar_id":"default-settlement","lag_business_days":1}]},"instruments":[{"instrument_id":"demo-equity-acme","symbol":"ACME","quote_currency":"USD","tick_size":"0.01","lot_size":"1"}],"risk":{"max_gross_exposure":"1000000","max_leverage":"2","short_borrow_bps":0,"instrument_policies":[{"instrument_id":"demo-equity-acme","max_order_quantity":"1000","max_long_position":"1000","max_short_position":"1000","max_notional_exposure":"1000000","initial_margin_bps":5000,"maintenance_margin_bps":2500,"shorting_allowed":true}],"groups":[]},"execution":{"model":"completed_bar_v1","configuration":{"version":"2","participation_bps":5000,"fee_schedules":[{"schedule_id":"external-acme-fees-v1","instrument_id":"demo-equity-acme","settlement_currency":"USD","minimum":null,"maximum":null,"components":[{"name":"broker","currency":"USD","kind":"fixed","value":"0.25","rounding":"up","applies_to":"any"},{"name":"exchange","currency":"USD","kind":"notional_bps","value":10,"rounding":"up","applies_to":"any"}]}]}},"metadata":{"producer":"strategy-protocol-fixture"}}}} +{"strategy_protocol_version":"10","transcript_sequence":"2","direction":"strategy_to_engine","message":{"strategy_protocol_version":"10","strategy_sequence":"1","message_type":"ready","payload":{"strategy_name":"fixture-strategy","strategy_version":"1"}}} +{"strategy_protocol_version":"10","transcript_sequence":"3","direction":"engine_to_strategy","message":{"strategy_protocol_version":"10","strategy_sequence":"2","message_type":"event","payload":{"context":{"now":"2026-01-02T21:00:02.000000Z","portfolio":{"base_currency":"USD","cash":"10000","net_market_value":"0","long_market_value":"0","short_market_value":"0","gross_exposure":"0","equity":"10000","weights_available":true,"cash_weight":"1","cash_balances":[{"currency":"USD","amount":"10000","fx_rate":"1","base_value":"10000","interest":"0","base_interest":"0","settled_amount":"10000","unsettled_amount":"0","base_settled_value":"10000","base_unsettled_value":"0"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"0","mark":"104","base_market_value":"0","weight":"0","settled_quantity":"0","unsettled_quantity":"0"}],"group_exposures":[]},"working_orders":[],"latest_bars":[{"instrument_id":"demo-equity-acme","open":"100","high":"105","low":"99","close":"104","volume":"100"}]},"event":{"type":"market_slice_closed","market_slice":{"slice_sequence":"1","start_at":"2026-01-02T14:30:00.000000Z","end_at":"2026-01-02T21:00:00.000000Z","available_at":"2026-01-02T21:00:01.000000Z","received_at":"2026-01-02T21:00:02.000000Z","bars":[{"instrument_id":"demo-equity-acme","open":"100","high":"105","low":"99","close":"104","volume":"100"}],"fx_rates":[{"currency":"USD","rate":"1"}],"corporate_actions":[],"borrow_observations":[{"instrument_id":"demo-equity-acme","effective_at":"2026-01-02T14:30:00.000000Z","available_quantity":"1000","annual_rate_bps":100,"recalled":false}],"cash_rate_observations":[{"currency":"USD","effective_at":"2026-01-02T14:30:00.000000Z","credit_rate_bps":0,"debit_rate_bps":0}],"settlement_failures":[],"lifecycle_events":[]}}}}} +{"strategy_protocol_version":"10","transcript_sequence":"4","direction":"strategy_to_engine","message":{"strategy_protocol_version":"10","strategy_sequence":"2","message_type":"intents","payload":{"intents":[{"type":"target_quantities","targets":[{"instrument_id":"demo-equity-acme","quantity":"2"}]},{"type":"emit_metric","name":"fixture_signal","value":"2"}]}}} +{"strategy_protocol_version":"10","transcript_sequence":"5","direction":"engine_to_strategy","message":{"strategy_protocol_version":"10","strategy_sequence":"3","message_type":"event","payload":{"context":{"now":"2026-01-02T21:00:02.000000Z","portfolio":{"base_currency":"USD","cash":"10000","net_market_value":"0","long_market_value":"0","short_market_value":"0","gross_exposure":"0","equity":"10000","weights_available":true,"cash_weight":"1","cash_balances":[{"currency":"USD","amount":"10000","fx_rate":"1","base_value":"10000","interest":"0","base_interest":"0","settled_amount":"10000","unsettled_amount":"0","base_settled_value":"10000","base_unsettled_value":"0"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"0","mark":"104","base_market_value":"0","weight":"0","settled_quantity":"0","unsettled_quantity":"0"}],"group_exposures":[]},"working_orders":[{"order_id":"external-demo-order-000000000001","instrument_id":"demo-equity-acme","side":"buy","quantity":"2","order_kind":"market","trigger_price":null,"limit_price":null,"time_in_force":"ioc","venue_id":null,"calendar_id":null,"expires_at":null,"origin":"target_rebalance","created_event_id":"external-demo-event-000000000007","updated_event_id":"external-demo-event-000000000007","created_sequence":"7","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","triggered_at":null,"triggered_slice_sequence":null,"filled_quantity":"0","filled_notional":"0","status":"working","rejection_reason":null}],"latest_bars":[{"instrument_id":"demo-equity-acme","open":"100","high":"105","low":"99","close":"104","volume":"100"}]},"event":{"type":"order_updated","order":{"order_id":"external-demo-order-000000000001","instrument_id":"demo-equity-acme","side":"buy","quantity":"2","order_kind":"market","trigger_price":null,"limit_price":null,"time_in_force":"ioc","venue_id":null,"calendar_id":null,"expires_at":null,"origin":"target_rebalance","created_event_id":"external-demo-event-000000000007","updated_event_id":"external-demo-event-000000000007","created_sequence":"7","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","triggered_at":null,"triggered_slice_sequence":null,"filled_quantity":"0","filled_notional":"0","status":"working","rejection_reason":null}}}}} +{"strategy_protocol_version":"10","transcript_sequence":"6","direction":"strategy_to_engine","message":{"strategy_protocol_version":"10","strategy_sequence":"3","message_type":"intents","payload":{"intents":[]}}} +{"strategy_protocol_version":"10","transcript_sequence":"7","direction":"engine_to_strategy","message":{"strategy_protocol_version":"10","strategy_sequence":"4","message_type":"event","payload":{"context":{"now":"2026-01-05T21:00:02.000000Z","portfolio":{"base_currency":"USD","cash":"9793.544","net_market_value":"214","long_market_value":"214","short_market_value":"0","gross_exposure":"214","equity":"10007.544","weights_available":true,"cash_weight":"0.978616","cash_balances":[{"currency":"USD","amount":"9793.544","fx_rate":"1","base_value":"9793.544","interest":"0","base_interest":"0","settled_amount":"10000","unsettled_amount":"-206.456","base_settled_value":"10000","base_unsettled_value":"-206.456"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"2","mark":"107","base_market_value":"214","weight":"0.021383","settled_quantity":"0","unsettled_quantity":"2"}],"group_exposures":[]},"working_orders":[],"latest_bars":[{"instrument_id":"demo-equity-acme","open":"103","high":"108","low":"102","close":"107","volume":"100"}]},"event":{"type":"fill_received","fill":{"fill_id":"external-demo-fill-000000000001","order_id":"external-demo-order-000000000001","instrument_id":"demo-equity-acme","quote_currency":"USD","side":"buy","quantity":"2","price":"103","notional":"206","fee":"0.456","executed_at":"2026-01-05T14:30:00.000000Z","slice_sequence":"2","fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"0.25","quote_amount":"0.25"},{"name":"exchange","kind":"notional_bps","currency":"USD","amount":"0.206","quote_amount":"0.206"}]}}}}} +{"strategy_protocol_version":"10","transcript_sequence":"8","direction":"strategy_to_engine","message":{"strategy_protocol_version":"10","strategy_sequence":"4","message_type":"intents","payload":{"intents":[]}}} +{"strategy_protocol_version":"10","transcript_sequence":"9","direction":"engine_to_strategy","message":{"strategy_protocol_version":"10","strategy_sequence":"5","message_type":"event","payload":{"context":{"now":"2026-01-05T21:00:02.000000Z","portfolio":{"base_currency":"USD","cash":"9793.544","net_market_value":"214","long_market_value":"214","short_market_value":"0","gross_exposure":"214","equity":"10007.544","weights_available":true,"cash_weight":"0.978616","cash_balances":[{"currency":"USD","amount":"9793.544","fx_rate":"1","base_value":"9793.544","interest":"0","base_interest":"0","settled_amount":"10000","unsettled_amount":"-206.456","base_settled_value":"10000","base_unsettled_value":"-206.456"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"2","mark":"107","base_market_value":"214","weight":"0.021383","settled_quantity":"0","unsettled_quantity":"2"}],"group_exposures":[]},"working_orders":[],"latest_bars":[{"instrument_id":"demo-equity-acme","open":"103","high":"108","low":"102","close":"107","volume":"100"}]},"event":{"type":"order_updated","order":{"order_id":"external-demo-order-000000000001","instrument_id":"demo-equity-acme","side":"buy","quantity":"2","order_kind":"market","trigger_price":null,"limit_price":null,"time_in_force":"ioc","venue_id":null,"calendar_id":null,"expires_at":null,"origin":"target_rebalance","created_event_id":"external-demo-event-000000000007","updated_event_id":"external-demo-event-000000000007","created_sequence":"7","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","triggered_at":null,"triggered_slice_sequence":null,"filled_quantity":"2","filled_notional":"206","status":"filled","rejection_reason":null}}}}} +{"strategy_protocol_version":"10","transcript_sequence":"10","direction":"strategy_to_engine","message":{"strategy_protocol_version":"10","strategy_sequence":"5","message_type":"intents","payload":{"intents":[]}}} +{"strategy_protocol_version":"10","transcript_sequence":"11","direction":"engine_to_strategy","message":{"strategy_protocol_version":"10","strategy_sequence":"6","message_type":"event","payload":{"context":{"now":"2026-01-05T21:00:02.000000Z","portfolio":{"base_currency":"USD","cash":"9793.544","net_market_value":"214","long_market_value":"214","short_market_value":"0","gross_exposure":"214","equity":"10007.544","weights_available":true,"cash_weight":"0.978616","cash_balances":[{"currency":"USD","amount":"9793.544","fx_rate":"1","base_value":"9793.544","interest":"0","base_interest":"0","settled_amount":"10000","unsettled_amount":"-206.456","base_settled_value":"10000","base_unsettled_value":"-206.456"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"2","mark":"107","base_market_value":"214","weight":"0.021383","settled_quantity":"0","unsettled_quantity":"2"}],"group_exposures":[]},"working_orders":[],"latest_bars":[{"instrument_id":"demo-equity-acme","open":"103","high":"108","low":"102","close":"107","volume":"100"}]},"event":{"type":"market_slice_closed","market_slice":{"slice_sequence":"2","start_at":"2026-01-05T14:30:00.000000Z","end_at":"2026-01-05T21:00:00.000000Z","available_at":"2026-01-05T21:00:01.000000Z","received_at":"2026-01-05T21:00:02.000000Z","bars":[{"instrument_id":"demo-equity-acme","open":"103","high":"108","low":"102","close":"107","volume":"100"}],"fx_rates":[{"currency":"USD","rate":"1"}],"corporate_actions":[],"borrow_observations":[{"instrument_id":"demo-equity-acme","effective_at":"2026-01-05T14:30:00.000000Z","available_quantity":"1000","annual_rate_bps":100,"recalled":false}],"cash_rate_observations":[{"currency":"USD","effective_at":"2026-01-05T14:30:00.000000Z","credit_rate_bps":0,"debit_rate_bps":0}],"settlement_failures":[],"lifecycle_events":[]}}}}} +{"strategy_protocol_version":"10","transcript_sequence":"12","direction":"strategy_to_engine","message":{"strategy_protocol_version":"10","strategy_sequence":"6","message_type":"intents","payload":{"intents":[]}}} +{"strategy_protocol_version":"10","transcript_sequence":"13","direction":"engine_to_strategy","message":{"strategy_protocol_version":"10","strategy_sequence":"7","message_type":"shutdown","payload":{}}} +{"strategy_protocol_version":"10","transcript_sequence":"14","direction":"strategy_to_engine","message":{"strategy_protocol_version":"10","strategy_sequence":"7","message_type":"stopped","payload":{}}} diff --git a/contracts/strategy/v10/message.schema.json b/contracts/strategy/v10/message.schema.json new file mode 100644 index 0000000..cf722e9 --- /dev/null +++ b/contracts/strategy/v10/message.schema.json @@ -0,0 +1,302 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/fallblu/trading-engine/contracts/strategy/v10/message.schema.json", + "title": "Trading Engine external strategy protocol v10 message", + "description": "One strict request or response in the synchronous JSON Lines strategy protocol. The engine additionally enforces direction, sequence pairing, canonical values, response limits, and lifecycle order.", + "oneOf": [ + { "$ref": "#/$defs/initialize" }, + { "$ref": "#/$defs/ready" }, + { "$ref": "#/$defs/event" }, + { "$ref": "#/$defs/intents" }, + { "$ref": "#/$defs/shutdown" }, + { "$ref": "#/$defs/stopped" }, + { "$ref": "#/$defs/error" } + ], + "$defs": { + "sequence": { + "type": "string", + "pattern": "^[1-9][0-9]*$" + }, + "base": { + "type": "object", + "additionalProperties": false, + "required": ["strategy_protocol_version", "strategy_sequence", "message_type", "payload"], + "properties": { + "strategy_protocol_version": { "const": "10" }, + "strategy_sequence": { "$ref": "#/$defs/sequence" }, + "message_type": { "type": "string" }, + "payload": { "type": "object" } + } + }, + "initialize": { + "allOf": [ + { "$ref": "#/$defs/base" }, + { + "properties": { + "message_type": { "const": "initialize" }, + "payload": { "$ref": "#/$defs/initializePayload" } + } + } + ] + }, + "initializePayload": { + "type": "object", + "additionalProperties": false, + "required": ["engine_version", "scenario_contract_version", "scenario_sha256", "run_id", "base_currency", "initial_cash", "initial_portfolio", "instruments", "venue_calendars", "risk", "execution", "financing", "settlement", "metadata"], + "properties": { + "engine_version": { "type": "string", "minLength": 1 }, + "scenario_contract_version": { "const": "12" }, + "scenario_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "run_id": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/identifier" }, + "base_currency": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/identifier" }, + "initial_cash": { + "type": "array", + "minItems": 1, + "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/cashBalance" } + }, + "initial_portfolio": { + "oneOf": [ + { "type": "null" }, + { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/initialPortfolio" } + ] + }, + "instruments": { + "type": "array", + "minItems": 1, + "maxItems": 4096, + "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/instrument" } + }, + "venue_calendars": { + "type": "array", + "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/venueCalendar" } + }, + "risk": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/risk" }, + "execution": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/execution" }, + "financing": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/financing" }, + "settlement": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/settlement" }, + "metadata": { "type": "object" } + } + }, + "ready": { + "allOf": [ + { "$ref": "#/$defs/base" }, + { + "properties": { + "message_type": { "const": "ready" }, + "payload": { "$ref": "#/$defs/readyPayload" } + } + } + ] + }, + "readyPayload": { + "type": "object", + "additionalProperties": false, + "required": ["strategy_name", "strategy_version"], + "properties": { + "strategy_name": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/identifier" }, + "strategy_version": { + "oneOf": [ + { "type": "null" }, + { "type": "string", "minLength": 1, "pattern": "^\\S(?:.*\\S)?$" } + ] + } + } + }, + "event": { + "allOf": [ + { "$ref": "#/$defs/base" }, + { + "properties": { + "message_type": { "const": "event" }, + "payload": { "$ref": "#/$defs/eventPayload" } + } + } + ] + }, + "eventPayload": { + "type": "object", + "additionalProperties": false, + "required": ["context", "event"], + "properties": { + "context": { "$ref": "#/$defs/context" }, + "event": { "$ref": "#/$defs/strategyEvent" } + } + }, + "context": { + "type": "object", + "additionalProperties": false, + "required": ["now", "portfolio", "working_orders", "latest_bars"], + "properties": { + "now": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/timestamp" }, + "portfolio": { "$ref": "#/$defs/portfolio" }, + "working_orders": { + "type": "array", + "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/journal.schema.json#/$defs/order" } + }, + "latest_bars": { + "type": "array", + "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/bar" } + } + } + }, + "portfolio": { + "type": "object", + "additionalProperties": false, + "required": ["base_currency", "cash", "net_market_value", "long_market_value", "short_market_value", "gross_exposure", "equity", "weights_available", "cash_weight", "cash_balances", "positions", "group_exposures"], + "properties": { + "base_currency": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/identifier" }, + "cash": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/signedDecimal" }, + "net_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/signedDecimal" }, + "long_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/unsignedDecimal" }, + "short_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/unsignedDecimal" }, + "gross_exposure": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/unsignedDecimal" }, + "equity": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/signedDecimal" }, + "weights_available": { "type": "boolean" }, + "cash_weight": { "$ref": "#/$defs/optionalWeight" }, + "cash_balances": { + "type": "array", + "minItems": 1, + "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/journal.schema.json#/$defs/cashAttribution" } + }, + "positions": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/markedPosition" } + }, + "group_exposures": { + "type": "array", + "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/journal.schema.json#/$defs/groupExposure" } + } + } + }, + "optionalWeight": { + "oneOf": [ + { "type": "null" }, + { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/signedDecimal" } + ] + }, + "markedPosition": { + "type": "object", + "additionalProperties": false, + "required": ["instrument_id", "quantity", "settled_quantity", "unsettled_quantity", "mark", "base_market_value", "weight"], + "properties": { + "instrument_id": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/identifier" }, + "quantity": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/signedDecimal" }, + "settled_quantity": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/signedDecimal" }, + "unsettled_quantity": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/signedDecimal" }, + "mark": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/positiveDecimal" }, + "base_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/signedDecimal" }, + "weight": { "$ref": "#/$defs/optionalWeight" } + } + }, + "strategyEvent": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["type", "market_slice"], + "properties": { + "type": { "const": "market_slice_closed" }, + "market_slice": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/marketSlice" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "fill"], + "properties": { + "type": { "const": "fill_received" }, + "fill": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/journal.schema.json#/$defs/fill" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "order"], + "properties": { + "type": { "const": "order_updated" }, + "order": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/journal.schema.json#/$defs/order" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "reason"], + "properties": { + "type": { "const": "intent_rejected" }, + "reason": { "type": "string", "minLength": 1 } + } + } + ] + }, + "intents": { + "allOf": [ + { "$ref": "#/$defs/base" }, + { + "properties": { + "message_type": { "const": "intents" }, + "payload": { "$ref": "#/$defs/intentsPayload" } + } + } + ] + }, + "intentsPayload": { + "type": "object", + "additionalProperties": false, + "required": ["intents"], + "properties": { + "intents": { + "type": "array", + "maxItems": 4096, + "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/intent" } + } + } + }, + "shutdown": { + "allOf": [ + { "$ref": "#/$defs/base" }, + { + "properties": { + "message_type": { "const": "shutdown" }, + "payload": { "$ref": "#/$defs/emptyPayload" } + } + } + ] + }, + "stopped": { + "allOf": [ + { "$ref": "#/$defs/base" }, + { + "properties": { + "message_type": { "const": "stopped" }, + "payload": { "$ref": "#/$defs/emptyPayload" } + } + } + ] + }, + "emptyPayload": { + "type": "object", + "additionalProperties": false, + "maxProperties": 0 + }, + "error": { + "allOf": [ + { "$ref": "#/$defs/base" }, + { + "properties": { + "message_type": { "const": "error" }, + "payload": { "$ref": "#/$defs/errorPayload" } + } + } + ] + }, + "errorPayload": { + "type": "object", + "additionalProperties": false, + "required": ["message"], + "properties": { + "message": { "type": "string", "minLength": 1, "pattern": "^\\S(?:.*\\S)?$" } + } + } + } +} diff --git a/contracts/strategy/v10/transcript.schema.json b/contracts/strategy/v10/transcript.schema.json new file mode 100644 index 0000000..02d3ca0 --- /dev/null +++ b/contracts/strategy/v10/transcript.schema.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/fallblu/trading-engine/contracts/strategy/v10/transcript.schema.json", + "title": "Trading Engine external strategy protocol v10 transcript record", + "description": "One accepted exchange or rejected-response diagnostic retained from a supervised stdio strategy session.", + "oneOf": [ + { "$ref": "#/$defs/exchange" }, + { "$ref": "#/$defs/rejectedResponse" } + ], + "$defs": { + "canonicalSequence": { + "type": "string", + "pattern": "^[1-9][0-9]*$" + }, + "exchange": { + "type": "object", + "additionalProperties": false, + "required": ["strategy_protocol_version", "transcript_sequence", "direction", "message"], + "properties": { + "strategy_protocol_version": { "const": "10" }, + "transcript_sequence": { "$ref": "#/$defs/canonicalSequence" }, + "direction": { + "enum": ["engine_to_strategy", "strategy_to_engine"] + }, + "message": { + "$ref": "https://github.com/fallblu/trading-engine/contracts/strategy/v10/message.schema.json" + } + } + }, + "rejectedResponse": { + "type": "object", + "additionalProperties": false, + "required": [ + "strategy_diagnostic_version", + "transcript_sequence", + "record_type", + "expected_strategy_sequence", + "diagnostic", + "evidence" + ], + "properties": { + "strategy_diagnostic_version": { "const": "1" }, + "transcript_sequence": { "$ref": "#/$defs/canonicalSequence" }, + "record_type": { "const": "rejected_strategy_response" }, + "expected_strategy_sequence": { "$ref": "#/$defs/canonicalSequence" }, + "diagnostic": { "$ref": "#/$defs/diagnostic" }, + "evidence": { "$ref": "#/$defs/evidence" } + } + }, + "diagnostic": { + "allOf": [ + { + "$ref": "https://github.com/fallblu/trading-engine/contracts/diagnostic/v1/diagnostic.schema.json" + }, + { + "properties": { + "code": { "enum": ["strategy.protocol", "resource.limit"] }, + "phase": { "const": "strategy" } + } + } + ] + }, + "evidence": { + "type": "object", + "additionalProperties": false, + "required": ["encoding", "prefix", "observed_bytes", "truncated"], + "properties": { + "encoding": { "const": "hex" }, + "prefix": { + "type": "string", + "pattern": "^(?:[0-9a-f]{2}){0,256}$" + }, + "observed_bytes": { + "type": "integer", + "minimum": 0, + "maximum": 1048577 + }, + "truncated": { "type": "boolean" } + } + } + } +} diff --git a/contracts/v12/README.md b/contracts/v12/README.md new file mode 100644 index 0000000..970097d --- /dev/null +++ b/contracts/v12/README.md @@ -0,0 +1,85 @@ +# Trading Engine contract v12 + +This directory is the authoritative v12 process and file contract shared by Trading Engine and its +clients. Versions 11 through 3 remain readable during their client transitions. + +- `scenario.schema.json` validates batch replay inputs. +- `scenario-stream.schema.json` validates each JSON Lines scenario-stream record. +- `journal.schema.json` validates each JSON Lines audit record. +- The files under `fixtures/` form the canonical valid conformance corpus. +- `fill-clipped.scenario.json` and its journal exercise a leverage-limited partial fill. + +Version 7 requires exactly one explicit risk policy per catalog instrument. Each policy defines +order, signed-position, notional, initial-margin, maintenance-margin, and shorting limits. Versioned +risk groups have explicit membership, may overlap, and can constrain gross, long, short, absolute +net, and gross-to-equity concentration exposure. + +Runtime validation requires exact currency, position-mark, and FX coverage; known instruments; +lot-aligned quantities; tick-aligned positive marks; basis with the same sign as quantity; +nonnegative fee histories; the base FX rate equal to one; instrument, group, aggregate exposure, +leverage, and initial-margin limits. Signed cash is valid. A successful v12 run emits `initial_state` +immediately after `run_started`, followed by a reconciled initial `valuation`, before market data. + +Admission and fill clipping include working-order reservations. When multiple groups limit the same +fill, lexical group identity is the deterministic tie breaker. Valuations and strategy contexts +carry group exposure snapshots, and clipping thresholds identify the exact instrument or group. + +Every v12 scenario, stream record, and journal record carries `"contract_version": "12"`. + +Version 8 adds explicit `market`, `limit`, `stop`, and `stop_limit` orders with `gtc`, `ioc`, +`fok`, `day`, and `gtd` time-in-force policies. `day` orders identify both their venue and the +exact versioned calendar; `gtd` orders carry an absolute expiry timestamp. Older contracts retain +their frozen mapping: market orders are IOC and limit orders are GTC. + +Stops evaluate only completed OHLCV bars. A gap through the trigger records the bar start as the +trigger time; an intrabar touch records the bar end. Trigger state and slice sequence are journaled, +and an activated order cannot execute before the following slice. A stop becomes a market order; +a stop-limit becomes its configured limit order. Splits adjust both trigger and limit prices. + +IOC orders cancel any remainder after their first eligible slice. FOK orders fill only when the +full remaining quantity fits both execution capacity and risk capacity, otherwise they cancel with +no fill. DAY orders cancel after matching the slice that reaches the selected session's final +phase close. GTD orders cancel before matching any completed bar whose end reaches or passes the +expiry, avoiding ambiguous partial-bar execution. + +The v10 `execution` object uses `completed_bar_v1` configuration version `"2"`: participation basis +points plus exactly one composable fee schedule per instrument. Named fixed, notional-basis-point, +and per-unit components declare currency, rounding, and maker/taker applicability. Optional +per-fill minimums and caps use the schedule settlement currency; negative components represent +rebates. Fills and valuations retain every native, quote, and base-currency attribution. Runtime +capabilities also advertise frozen configuration version `"1"` for older scenario contracts. + +Version 10 adds a required `financing` policy and effective-time observations on every market +slice. Borrow observations provide per-instrument locate availability, signed annual rates, and +recall state. Cash observations provide separate annual credit and debit rates per currency. +Policies select Actual/365 or Actual/360 day count, simple or daily compounding, missing-data +handling, locate rejection or fill clipping, and recall rejection or deterministic close-out. + +Borrow availability is enforced when a fill would create or increase a short. Recalls cancel +active sells and may submit priority IOC covers until the position is flat. Borrow charges and cash +interest use the exact slice interval, update native ledgers deterministically, and emit dedicated +journal records. Valuations report cash interest separately and include it in aggregate realized +P&L. Version 9 and earlier retain their frozen fixed-borrow behavior and wire shapes. + +Version 11 separates trade-date economic accounting from settlement-date availability. A required +settlement policy selects total or settled cash buying power and total or settled position +availability. Versioned calendars enumerate canonical business dates, and each instrument has an +explicit business-day lag. Every fill creates a deterministic settlement instruction containing +its cash and position movements, trade date, and due date. A due instruction either settles on the +first eligible slice or records a named failure supplied by that slice. + +Valuations and strategy contexts report settled and unsettled cash and quantities without changing +economic equity. Journals include instruction-created, completed, and failed events. Scenario v10 +and strategy protocol v8 retain their frozen immediate-settlement wire behavior. + +Version 12 adds exact stock-dividend, rights, and spin-off distributions. Each distribution names +its destination instrument, exact entitlement ratio, basis allocation in basis points, and either +rejects fractional entitlements or converts them to cash at an explicit price and currency. +Stock dividends adjust persistent targets and eligible working orders; every distribution journals +delivered quantity, fractional quantity, allocated basis, fractional basis, and cash in lieu. + +Lifecycle events keep stable instrument identity separate from mutable symbol and provider +mappings. Halt and resume transitions control tradability. Expiration and delisting are terminal, +cancel active orders, clear target exposure, and require an explicit hold or cash-out policy. +Cash-out specifies its terminal price and currency. Every transition journals the source event, +resulting listing state, provider provenance, liquidated quantity, and cash attribution. diff --git a/contracts/v12/dune b/contracts/v12/dune new file mode 100644 index 0000000..5cff266 --- /dev/null +++ b/contracts/v12/dune @@ -0,0 +1,18 @@ +(install + (section share) + (package trading_engine) + (files + (journal.schema.json as contracts/v12/journal.schema.json) + (scenario-stream.schema.json as contracts/v12/scenario-stream.schema.json) + (scenario.schema.json as contracts/v12/scenario.schema.json) + (fixtures/demo.journal.jsonl as contracts/v12/fixtures/demo.journal.jsonl) + (fixtures/demo.scenario.json as contracts/v12/fixtures/demo.scenario.json) + (fixtures/demo.scenario.jsonl + as + contracts/v12/fixtures/demo.scenario.jsonl) + (fixtures/fill-clipped.journal.jsonl + as + contracts/v12/fixtures/fill-clipped.journal.jsonl) + (fixtures/fill-clipped.scenario.json + as + contracts/v12/fixtures/fill-clipped.scenario.json))) diff --git a/contracts/v12/fixtures/demo.journal.jsonl b/contracts/v12/fixtures/demo.journal.jsonl new file mode 100644 index 0000000..204d4df --- /dev/null +++ b/contracts/v12/fixtures/demo.journal.jsonl @@ -0,0 +1,31 @@ +{"contract_version":"12","engine_sequence":"1","event_id":"demo-event-000000000001","causation_ids":[],"run_id":"demo","recorded_at":"2026-01-02T21:00:02.000000Z","event_type":"run_started","payload":{"scenario_sha256":"ee80423182d458afa2458803c30af1d18f0a8d873bbfe4e16c510920a6aee7d3","execution_model":"completed_bar_v1"}} +{"contract_version":"12","engine_sequence":"2","event_id":"demo-event-000000000002","causation_ids":["demo-event-000000000001"],"run_id":"demo","recorded_at":"2026-01-02T21:00:02.000000Z","event_type":"initial_state","payload":{"portfolio":{"cash":[{"currency":"USD","amount":"10000"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"1","cost_basis":"90","realized_pnl":"5","dividend_pnl":"1","execution_fees":"0.5","borrow_fees":"0.25"}],"marks":[{"instrument_id":"demo-equity-acme","price":"100"}],"fx_rates":[{"currency":"USD","rate":"1"}]},"valuation":{"base_currency":"USD","cash":"10000","net_market_value":"100","long_market_value":"100","short_market_value":"0","gross_exposure":"100","cost_basis":"90","realized_pnl":"5","unrealized_pnl":"10","equity":"10100","dividend_pnl":"1","execution_fees":"0.5","borrow_fees":"0.25","total_fees":"0.75","cash_balances":[{"currency":"USD","amount":"10000","fx_rate":"1","base_value":"10000","interest":"0","base_interest":"0","settled_amount":"10000","unsettled_amount":"0","base_settled_value":"10000","base_unsettled_value":"0"}],"positions":[{"instrument_id":"demo-equity-acme","quote_currency":"USD","quantity":"1","mark":"100","fx_rate":"1","market_value":"100","base_market_value":"100","cost_basis":"90","base_cost_basis":"90","realized_pnl":"5","base_realized_pnl":"5","unrealized_pnl":"10","base_unrealized_pnl":"10","dividend_pnl":"1","base_dividend_pnl":"1","execution_fees":"0.5","base_execution_fees":"0.5","borrow_fees":"0.25","base_borrow_fees":"0.25","total_fees":"0.75","base_total_fees":"0.75","execution_fee_components":[],"settled_quantity":"1","unsettled_quantity":"0"}],"execution_fee_components":[],"cash_interest":"0","settled_cash":"10000","unsettled_cash":"0","margin":{"initial_requirement":"50","maintenance_requirement":"25","initial_excess":"10050","maintenance_excess":"10075","margin_call":false},"group_exposures":[]}}} +{"contract_version":"12","engine_sequence":"3","event_id":"demo-event-000000000003","causation_ids":["demo-event-000000000002"],"run_id":"demo","recorded_at":"2026-01-02T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"10000","net_market_value":"100","long_market_value":"100","short_market_value":"0","gross_exposure":"100","cost_basis":"90","realized_pnl":"5","unrealized_pnl":"10","equity":"10100","dividend_pnl":"1","execution_fees":"0.5","borrow_fees":"0.25","total_fees":"0.75","cash_balances":[{"currency":"USD","amount":"10000","fx_rate":"1","base_value":"10000","interest":"0","base_interest":"0","settled_amount":"10000","unsettled_amount":"0","base_settled_value":"10000","base_unsettled_value":"0"}],"positions":[{"instrument_id":"demo-equity-acme","quote_currency":"USD","quantity":"1","mark":"100","fx_rate":"1","market_value":"100","base_market_value":"100","cost_basis":"90","base_cost_basis":"90","realized_pnl":"5","base_realized_pnl":"5","unrealized_pnl":"10","base_unrealized_pnl":"10","dividend_pnl":"1","base_dividend_pnl":"1","execution_fees":"0.5","base_execution_fees":"0.5","borrow_fees":"0.25","base_borrow_fees":"0.25","total_fees":"0.75","base_total_fees":"0.75","execution_fee_components":[],"settled_quantity":"1","unsettled_quantity":"0"}],"execution_fee_components":[],"cash_interest":"0","settled_cash":"10000","unsettled_cash":"0","margin":{"initial_requirement":"50","maintenance_requirement":"25","initial_excess":"10050","maintenance_excess":"10075","margin_call":false},"group_exposures":[]}} +{"contract_version":"12","engine_sequence":"4","event_id":"demo-event-000000000004","causation_ids":[],"run_id":"demo","recorded_at":"2026-01-02T21:00:02.000000Z","event_type":"market_slice_received","payload":{"slice_sequence":"1","start_at":"2026-01-02T14:30:00.000000Z","end_at":"2026-01-02T21:00:00.000000Z","available_at":"2026-01-02T21:00:01.000000Z","received_at":"2026-01-02T21:00:02.000000Z","bars":[{"instrument_id":"demo-equity-acme","open":"100","high":"105","low":"99","close":"104","volume":"100"}],"fx_rates":[{"currency":"USD","rate":"1"}],"corporate_actions":[],"borrow_observations":[{"instrument_id":"demo-equity-acme","effective_at":"2026-01-02T14:30:00.000000Z","available_quantity":"1000","annual_rate_bps":100,"recalled":false}],"cash_rate_observations":[{"currency":"USD","effective_at":"2026-01-02T14:30:00.000000Z","credit_rate_bps":100,"debit_rate_bps":200}],"settlement_failures":[],"lifecycle_events":[]}} +{"contract_version":"12","engine_sequence":"5","event_id":"demo-event-000000000005","causation_ids":["demo-event-000000000004"],"run_id":"demo","recorded_at":"2026-01-02T21:00:02.000000Z","event_type":"cash_interest_applied","payload":{"observation":{"currency":"USD","effective_at":"2026-01-02T14:30:00.000000Z","credit_rate_bps":100,"debit_rate_bps":200},"opening_balance":"10000","applied_rate_bps":100,"day_count":"actual_365","compounding":"simple","period_start":"2026-01-02T14:30:00.000000Z","period_end":"2026-01-02T21:00:00.000000Z","amount":"0.074201","closing_balance":"10000.074201"}} +{"contract_version":"12","engine_sequence":"6","event_id":"demo-event-000000000006","causation_ids":["demo-event-000000000004"],"run_id":"demo","recorded_at":"2026-01-02T21:00:02.000000Z","event_type":"target_portfolio_requested","payload":{"basis":"weights","targets":[{"instrument_id":"demo-equity-acme","weight":"0.1","quantity":"9.715","reference_price":"104"}]}} +{"contract_version":"12","engine_sequence":"7","event_id":"demo-event-000000000007","causation_ids":["demo-event-000000000004"],"run_id":"demo","recorded_at":"2026-01-02T21:00:02.000000Z","event_type":"metric_emitted","payload":{"name":"desired_weight","value":"0.1"}} +{"contract_version":"12","engine_sequence":"8","event_id":"demo-event-000000000008","causation_ids":["demo-event-000000000004","demo-event-000000000006"],"run_id":"demo","recorded_at":"2026-01-02T21:00:02.000000Z","event_type":"order_accepted","payload":{"order_id":"demo-order-000000000001","instrument_id":"demo-equity-acme","side":"buy","quantity":"8.715","order_kind":"market","trigger_price":null,"limit_price":null,"time_in_force":"ioc","venue_id":null,"calendar_id":null,"expires_at":null,"origin":"target_rebalance","created_event_id":"demo-event-000000000008","updated_event_id":"demo-event-000000000008","created_sequence":"8","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","triggered_at":null,"triggered_slice_sequence":null,"filled_quantity":"0","filled_notional":"0","status":"working","rejection_reason":null}} +{"contract_version":"12","engine_sequence":"9","event_id":"demo-event-000000000009","causation_ids":["demo-event-000000000004"],"run_id":"demo","recorded_at":"2026-01-02T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"10000.074201","net_market_value":"104","long_market_value":"104","short_market_value":"0","gross_exposure":"104","cost_basis":"90","realized_pnl":"5.074201","unrealized_pnl":"14","equity":"10104.074201","dividend_pnl":"1","execution_fees":"0.5","borrow_fees":"0.25","total_fees":"0.75","cash_balances":[{"currency":"USD","amount":"10000.074201","fx_rate":"1","base_value":"10000.074201","interest":"0.074201","base_interest":"0.074201","settled_amount":"10000.074201","unsettled_amount":"0","base_settled_value":"10000.074201","base_unsettled_value":"0"}],"positions":[{"instrument_id":"demo-equity-acme","quote_currency":"USD","quantity":"1","mark":"104","fx_rate":"1","market_value":"104","base_market_value":"104","cost_basis":"90","base_cost_basis":"90","realized_pnl":"5","base_realized_pnl":"5","unrealized_pnl":"14","base_unrealized_pnl":"14","dividend_pnl":"1","base_dividend_pnl":"1","execution_fees":"0.5","base_execution_fees":"0.5","borrow_fees":"0.25","base_borrow_fees":"0.25","total_fees":"0.75","base_total_fees":"0.75","execution_fee_components":[],"settled_quantity":"1","unsettled_quantity":"0"}],"execution_fee_components":[],"cash_interest":"0.074201","settled_cash":"10000.074201","unsettled_cash":"0","margin":{"initial_requirement":"52","maintenance_requirement":"26","initial_excess":"10052.074201","maintenance_excess":"10078.074201","margin_call":false},"group_exposures":[]}} +{"contract_version":"12","engine_sequence":"10","event_id":"demo-event-000000000010","causation_ids":[],"run_id":"demo","recorded_at":"2026-01-05T21:00:02.000000Z","event_type":"market_slice_received","payload":{"slice_sequence":"2","start_at":"2026-01-05T14:30:00.000000Z","end_at":"2026-01-05T21:00:00.000000Z","available_at":"2026-01-05T21:00:01.000000Z","received_at":"2026-01-05T21:00:02.000000Z","bars":[{"instrument_id":"demo-equity-acme","open":"103","high":"108","low":"102","close":"107","volume":"12"}],"fx_rates":[{"currency":"USD","rate":"1"}],"corporate_actions":[],"borrow_observations":[{"instrument_id":"demo-equity-acme","effective_at":"2026-01-05T14:30:00.000000Z","available_quantity":"1000","annual_rate_bps":100,"recalled":false}],"cash_rate_observations":[{"currency":"USD","effective_at":"2026-01-05T14:30:00.000000Z","credit_rate_bps":100,"debit_rate_bps":200}],"settlement_failures":[],"lifecycle_events":[]}} +{"contract_version":"12","engine_sequence":"11","event_id":"demo-event-000000000011","causation_ids":["demo-event-000000000010"],"run_id":"demo","recorded_at":"2026-01-05T21:00:02.000000Z","event_type":"cash_interest_applied","payload":{"observation":{"currency":"USD","effective_at":"2026-01-05T14:30:00.000000Z","credit_rate_bps":100,"debit_rate_bps":200},"opening_balance":"10000.074201","applied_rate_bps":100,"day_count":"actual_365","compounding":"simple","period_start":"2026-01-05T14:30:00.000000Z","period_end":"2026-01-05T21:00:00.000000Z","amount":"0.074201","closing_balance":"10000.148402"}} +{"contract_version":"12","engine_sequence":"12","event_id":"demo-event-000000000012","causation_ids":["demo-event-000000000008","demo-event-000000000010"],"run_id":"demo","recorded_at":"2026-01-05T21:00:02.000000Z","event_type":"fill_applied","payload":{"fill_id":"demo-fill-000000000001","order_id":"demo-order-000000000001","instrument_id":"demo-equity-acme","quote_currency":"USD","side":"buy","quantity":"6","price":"103","notional":"618","fee":"0.868","executed_at":"2026-01-05T14:30:00.000000Z","slice_sequence":"2","fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"0.25","quote_amount":"0.25"},{"name":"exchange","kind":"notional_bps","currency":"USD","amount":"0.618","quote_amount":"0.618"}]}} +{"contract_version":"12","engine_sequence":"13","event_id":"demo-event-000000000013","causation_ids":["demo-event-000000000012"],"run_id":"demo","recorded_at":"2026-01-05T21:00:02.000000Z","event_type":"settlement_instruction_created","payload":{"instruction_id":"demo-fill-000000000001-settlement","fill_id":"demo-fill-000000000001","instrument_id":"demo-equity-acme","currency":"USD","cash_movement":"-618.868","position_movement":"6","trade_date":"2026-01-05","due_date":"2026-01-06","status":"pending","settled_at":null,"failed_at":null,"failure_reason":null}} +{"contract_version":"12","engine_sequence":"14","event_id":"demo-event-000000000014","causation_ids":["demo-event-000000000008","demo-event-000000000010"],"run_id":"demo","recorded_at":"2026-01-05T21:00:02.000000Z","event_type":"order_cancelled","payload":{"order":{"order_id":"demo-order-000000000001","instrument_id":"demo-equity-acme","side":"buy","quantity":"8.715","order_kind":"market","trigger_price":null,"limit_price":null,"time_in_force":"ioc","venue_id":null,"calendar_id":null,"expires_at":null,"origin":"target_rebalance","created_event_id":"demo-event-000000000008","updated_event_id":"demo-event-000000000008","created_sequence":"8","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","triggered_at":null,"triggered_slice_sequence":null,"filled_quantity":"6","filled_notional":"618","status":"cancelled","rejection_reason":null},"reason":"market_ioc"}} +{"contract_version":"12","engine_sequence":"15","event_id":"demo-event-000000000015","causation_ids":["demo-event-000000000006","demo-event-000000000010"],"run_id":"demo","recorded_at":"2026-01-05T21:00:02.000000Z","event_type":"order_accepted","payload":{"order_id":"demo-order-000000000002","instrument_id":"demo-equity-acme","side":"buy","quantity":"2.715","order_kind":"market","trigger_price":null,"limit_price":null,"time_in_force":"ioc","venue_id":null,"calendar_id":null,"expires_at":null,"origin":"target_rebalance","created_event_id":"demo-event-000000000015","updated_event_id":"demo-event-000000000015","created_sequence":"15","created_at":"2026-01-05T21:00:02.000000Z","eligible_after_slice_sequence":"2","triggered_at":null,"triggered_slice_sequence":null,"filled_quantity":"0","filled_notional":"0","status":"working","rejection_reason":null}} +{"contract_version":"12","engine_sequence":"16","event_id":"demo-event-000000000016","causation_ids":["demo-event-000000000010"],"run_id":"demo","recorded_at":"2026-01-05T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"9381.280402","net_market_value":"749","long_market_value":"749","short_market_value":"0","gross_exposure":"749","cost_basis":"708.868","realized_pnl":"5.148402","unrealized_pnl":"40.132","equity":"10130.280402","dividend_pnl":"1","execution_fees":"1.368","borrow_fees":"0.25","total_fees":"1.618","cash_balances":[{"currency":"USD","amount":"9381.280402","fx_rate":"1","base_value":"9381.280402","interest":"0.148402","base_interest":"0.148402","settled_amount":"10000.148402","unsettled_amount":"-618.868","base_settled_value":"10000.148402","base_unsettled_value":"-618.868"}],"positions":[{"instrument_id":"demo-equity-acme","quote_currency":"USD","quantity":"7","mark":"107","fx_rate":"1","market_value":"749","base_market_value":"749","cost_basis":"708.868","base_cost_basis":"708.868","realized_pnl":"5","base_realized_pnl":"5","unrealized_pnl":"40.132","base_unrealized_pnl":"40.132","dividend_pnl":"1","base_dividend_pnl":"1","execution_fees":"1.368","base_execution_fees":"1.368","borrow_fees":"0.25","base_borrow_fees":"0.25","total_fees":"1.618","base_total_fees":"1.618","execution_fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"0.25","quote_currency":"USD","quote_amount":"0.25","base_amount":"0.25"},{"name":"exchange","kind":"notional_bps","currency":"USD","amount":"0.618","quote_currency":"USD","quote_amount":"0.618","base_amount":"0.618"}],"settled_quantity":"1","unsettled_quantity":"6"}],"execution_fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"0.25","quote_currency":"USD","quote_amount":"0.25","base_amount":"0.25"},{"name":"exchange","kind":"notional_bps","currency":"USD","amount":"0.618","quote_currency":"USD","quote_amount":"0.618","base_amount":"0.618"}],"cash_interest":"0.148402","settled_cash":"10000.148402","unsettled_cash":"-618.868","margin":{"initial_requirement":"374.5","maintenance_requirement":"187.25","initial_excess":"9755.780402","maintenance_excess":"9943.030402","margin_call":false},"group_exposures":[]}} +{"contract_version":"12","engine_sequence":"17","event_id":"demo-event-000000000017","causation_ids":[],"run_id":"demo","recorded_at":"2026-01-06T21:00:02.000000Z","event_type":"market_slice_received","payload":{"slice_sequence":"3","start_at":"2026-01-06T14:30:00.000000Z","end_at":"2026-01-06T21:00:00.000000Z","available_at":"2026-01-06T21:00:01.000000Z","received_at":"2026-01-06T21:00:02.000000Z","bars":[{"instrument_id":"demo-equity-acme","open":"107","high":"109","low":"104","close":"105","volume":"100"}],"fx_rates":[{"currency":"USD","rate":"1"}],"corporate_actions":[],"borrow_observations":[{"instrument_id":"demo-equity-acme","effective_at":"2026-01-06T14:30:00.000000Z","available_quantity":"1000","annual_rate_bps":100,"recalled":false}],"cash_rate_observations":[{"currency":"USD","effective_at":"2026-01-06T14:30:00.000000Z","credit_rate_bps":100,"debit_rate_bps":200}],"settlement_failures":[],"lifecycle_events":[]}} +{"contract_version":"12","engine_sequence":"18","event_id":"demo-event-000000000018","causation_ids":["demo-event-000000000017"],"run_id":"demo","recorded_at":"2026-01-06T21:00:02.000000Z","event_type":"settlement_completed","payload":{"instruction_id":"demo-fill-000000000001-settlement","fill_id":"demo-fill-000000000001","instrument_id":"demo-equity-acme","currency":"USD","cash_movement":"-618.868","position_movement":"6","trade_date":"2026-01-05","due_date":"2026-01-06","status":"settled","settled_at":"2026-01-06T14:30:00.000000Z","failed_at":null,"failure_reason":null}} +{"contract_version":"12","engine_sequence":"19","event_id":"demo-event-000000000019","causation_ids":["demo-event-000000000017"],"run_id":"demo","recorded_at":"2026-01-06T21:00:02.000000Z","event_type":"cash_interest_applied","payload":{"observation":{"currency":"USD","effective_at":"2026-01-06T14:30:00.000000Z","credit_rate_bps":100,"debit_rate_bps":200},"opening_balance":"9381.280402","applied_rate_bps":100,"day_count":"actual_365","compounding":"simple","period_start":"2026-01-06T14:30:00.000000Z","period_end":"2026-01-06T21:00:00.000000Z","amount":"0.06961","closing_balance":"9381.350012"}} +{"contract_version":"12","engine_sequence":"20","event_id":"demo-event-000000000020","causation_ids":["demo-event-000000000015","demo-event-000000000017"],"run_id":"demo","recorded_at":"2026-01-06T21:00:02.000000Z","event_type":"fill_applied","payload":{"fill_id":"demo-fill-000000000002","order_id":"demo-order-000000000002","instrument_id":"demo-equity-acme","quote_currency":"USD","side":"buy","quantity":"2.715","price":"107","notional":"290.505","fee":"0.540505","executed_at":"2026-01-06T14:30:00.000000Z","slice_sequence":"3","fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"0.25","quote_amount":"0.25"},{"name":"exchange","kind":"notional_bps","currency":"USD","amount":"0.290505","quote_amount":"0.290505"}]}} +{"contract_version":"12","engine_sequence":"21","event_id":"demo-event-000000000021","causation_ids":["demo-event-000000000020"],"run_id":"demo","recorded_at":"2026-01-06T21:00:02.000000Z","event_type":"settlement_instruction_created","payload":{"instruction_id":"demo-fill-000000000002-settlement","fill_id":"demo-fill-000000000002","instrument_id":"demo-equity-acme","currency":"USD","cash_movement":"-291.045505","position_movement":"2.715","trade_date":"2026-01-06","due_date":"2026-01-07","status":"pending","settled_at":null,"failed_at":null,"failure_reason":null}} +{"contract_version":"12","engine_sequence":"22","event_id":"demo-event-000000000022","causation_ids":["demo-event-000000000017"],"run_id":"demo","recorded_at":"2026-01-06T21:00:02.000000Z","event_type":"target_portfolio_requested","payload":{"basis":"quantities","targets":[{"instrument_id":"demo-equity-acme","weight":null,"quantity":"2.5","reference_price":null}]}} +{"contract_version":"12","engine_sequence":"23","event_id":"demo-event-000000000023","causation_ids":["demo-event-000000000017","demo-event-000000000022"],"run_id":"demo","recorded_at":"2026-01-06T21:00:02.000000Z","event_type":"order_accepted","payload":{"order_id":"demo-order-000000000003","instrument_id":"demo-equity-acme","side":"sell","quantity":"7.215","order_kind":"market","trigger_price":null,"limit_price":null,"time_in_force":"ioc","venue_id":null,"calendar_id":null,"expires_at":null,"origin":"target_rebalance","created_event_id":"demo-event-000000000023","updated_event_id":"demo-event-000000000023","created_sequence":"23","created_at":"2026-01-06T21:00:02.000000Z","eligible_after_slice_sequence":"3","triggered_at":null,"triggered_slice_sequence":null,"filled_quantity":"0","filled_notional":"0","status":"working","rejection_reason":null}} +{"contract_version":"12","engine_sequence":"24","event_id":"demo-event-000000000024","causation_ids":["demo-event-000000000017"],"run_id":"demo","recorded_at":"2026-01-06T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"9090.304507","net_market_value":"1020.075","long_market_value":"1020.075","short_market_value":"0","gross_exposure":"1020.075","cost_basis":"999.913505","realized_pnl":"5.218012","unrealized_pnl":"20.161495","equity":"10110.379507","dividend_pnl":"1","execution_fees":"1.908505","borrow_fees":"0.25","total_fees":"2.158505","cash_balances":[{"currency":"USD","amount":"9090.304507","fx_rate":"1","base_value":"9090.304507","interest":"0.218012","base_interest":"0.218012","settled_amount":"9381.350012","unsettled_amount":"-291.045505","base_settled_value":"9381.350012","base_unsettled_value":"-291.045505"}],"positions":[{"instrument_id":"demo-equity-acme","quote_currency":"USD","quantity":"9.715","mark":"105","fx_rate":"1","market_value":"1020.075","base_market_value":"1020.075","cost_basis":"999.913505","base_cost_basis":"999.913505","realized_pnl":"5","base_realized_pnl":"5","unrealized_pnl":"20.161495","base_unrealized_pnl":"20.161495","dividend_pnl":"1","base_dividend_pnl":"1","execution_fees":"1.908505","base_execution_fees":"1.908505","borrow_fees":"0.25","base_borrow_fees":"0.25","total_fees":"2.158505","base_total_fees":"2.158505","execution_fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"0.5","quote_currency":"USD","quote_amount":"0.5","base_amount":"0.5"},{"name":"exchange","kind":"notional_bps","currency":"USD","amount":"0.908505","quote_currency":"USD","quote_amount":"0.908505","base_amount":"0.908505"}],"settled_quantity":"7","unsettled_quantity":"2.715"}],"execution_fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"0.5","quote_currency":"USD","quote_amount":"0.5","base_amount":"0.5"},{"name":"exchange","kind":"notional_bps","currency":"USD","amount":"0.908505","quote_currency":"USD","quote_amount":"0.908505","base_amount":"0.908505"}],"cash_interest":"0.218012","settled_cash":"9381.350012","unsettled_cash":"-291.045505","margin":{"initial_requirement":"510.0375","maintenance_requirement":"255.01875","initial_excess":"9600.342007","maintenance_excess":"9855.360757","margin_call":false},"group_exposures":[]}} +{"contract_version":"12","engine_sequence":"25","event_id":"demo-event-000000000025","causation_ids":[],"run_id":"demo","recorded_at":"2026-01-07T21:00:02.000000Z","event_type":"market_slice_received","payload":{"slice_sequence":"4","start_at":"2026-01-07T14:30:00.000000Z","end_at":"2026-01-07T21:00:00.000000Z","available_at":"2026-01-07T21:00:01.000000Z","received_at":"2026-01-07T21:00:02.000000Z","bars":[{"instrument_id":"demo-equity-acme","open":"105","high":"107","low":"103","close":"106","volume":"100"}],"fx_rates":[{"currency":"USD","rate":"1"}],"corporate_actions":[],"borrow_observations":[{"instrument_id":"demo-equity-acme","effective_at":"2026-01-07T14:30:00.000000Z","available_quantity":"1000","annual_rate_bps":100,"recalled":false}],"cash_rate_observations":[{"currency":"USD","effective_at":"2026-01-07T14:30:00.000000Z","credit_rate_bps":100,"debit_rate_bps":200}],"settlement_failures":[],"lifecycle_events":[]}} +{"contract_version":"12","engine_sequence":"26","event_id":"demo-event-000000000026","causation_ids":["demo-event-000000000025"],"run_id":"demo","recorded_at":"2026-01-07T21:00:02.000000Z","event_type":"settlement_completed","payload":{"instruction_id":"demo-fill-000000000002-settlement","fill_id":"demo-fill-000000000002","instrument_id":"demo-equity-acme","currency":"USD","cash_movement":"-291.045505","position_movement":"2.715","trade_date":"2026-01-06","due_date":"2026-01-07","status":"settled","settled_at":"2026-01-07T14:30:00.000000Z","failed_at":null,"failure_reason":null}} +{"contract_version":"12","engine_sequence":"27","event_id":"demo-event-000000000027","causation_ids":["demo-event-000000000025"],"run_id":"demo","recorded_at":"2026-01-07T21:00:02.000000Z","event_type":"cash_interest_applied","payload":{"observation":{"currency":"USD","effective_at":"2026-01-07T14:30:00.000000Z","credit_rate_bps":100,"debit_rate_bps":200},"opening_balance":"9090.304507","applied_rate_bps":100,"day_count":"actual_365","compounding":"simple","period_start":"2026-01-07T14:30:00.000000Z","period_end":"2026-01-07T21:00:00.000000Z","amount":"0.067451","closing_balance":"9090.371958"}} +{"contract_version":"12","engine_sequence":"28","event_id":"demo-event-000000000028","causation_ids":["demo-event-000000000023","demo-event-000000000025"],"run_id":"demo","recorded_at":"2026-01-07T21:00:02.000000Z","event_type":"fill_applied","payload":{"fill_id":"demo-fill-000000000003","order_id":"demo-order-000000000003","instrument_id":"demo-equity-acme","quote_currency":"USD","side":"sell","quantity":"7.215","price":"105","notional":"757.575","fee":"1","executed_at":"2026-01-07T14:30:00.000000Z","slice_sequence":"4","fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"0.25","quote_amount":"0.25"},{"name":"exchange","kind":"notional_bps","currency":"USD","amount":"0.757575","quote_amount":"0.757575"},{"name":"maximum_adjustment","kind":"maximum_adjustment","currency":"USD","amount":"-0.007575","quote_amount":"-0.007575"}]}} +{"contract_version":"12","engine_sequence":"29","event_id":"demo-event-000000000029","causation_ids":["demo-event-000000000028"],"run_id":"demo","recorded_at":"2026-01-07T21:00:02.000000Z","event_type":"settlement_instruction_created","payload":{"instruction_id":"demo-fill-000000000003-settlement","fill_id":"demo-fill-000000000003","instrument_id":"demo-equity-acme","currency":"USD","cash_movement":"756.575","position_movement":"-7.215","trade_date":"2026-01-07","due_date":"2026-01-08","status":"pending","settled_at":null,"failed_at":null,"failure_reason":null}} +{"contract_version":"12","engine_sequence":"30","event_id":"demo-event-000000000030","causation_ids":["demo-event-000000000025"],"run_id":"demo","recorded_at":"2026-01-07T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"9846.946958","net_market_value":"265","long_market_value":"265","short_market_value":"0","gross_exposure":"265","cost_basis":"257.311762","realized_pnl":"19.25872","unrealized_pnl":"7.688238","equity":"10111.946958","dividend_pnl":"1","execution_fees":"2.908505","borrow_fees":"0.25","total_fees":"3.158505","cash_balances":[{"currency":"USD","amount":"9846.946958","fx_rate":"1","base_value":"9846.946958","interest":"0.285463","base_interest":"0.285463","settled_amount":"9090.371958","unsettled_amount":"756.575","base_settled_value":"9090.371958","base_unsettled_value":"756.575"}],"positions":[{"instrument_id":"demo-equity-acme","quote_currency":"USD","quantity":"2.5","mark":"106","fx_rate":"1","market_value":"265","base_market_value":"265","cost_basis":"257.311762","base_cost_basis":"257.311762","realized_pnl":"18.973257","base_realized_pnl":"18.973257","unrealized_pnl":"7.688238","base_unrealized_pnl":"7.688238","dividend_pnl":"1","base_dividend_pnl":"1","execution_fees":"2.908505","base_execution_fees":"2.908505","borrow_fees":"0.25","base_borrow_fees":"0.25","total_fees":"3.158505","base_total_fees":"3.158505","execution_fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"0.75","quote_currency":"USD","quote_amount":"0.75","base_amount":"0.75"},{"name":"exchange","kind":"notional_bps","currency":"USD","amount":"1.66608","quote_currency":"USD","quote_amount":"1.66608","base_amount":"1.66608"},{"name":"maximum_adjustment","kind":"maximum_adjustment","currency":"USD","amount":"-0.007575","quote_currency":"USD","quote_amount":"-0.007575","base_amount":"-0.007575"}],"settled_quantity":"9.715","unsettled_quantity":"-7.215"}],"execution_fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"0.75","quote_currency":"USD","quote_amount":"0.75","base_amount":"0.75"},{"name":"exchange","kind":"notional_bps","currency":"USD","amount":"1.66608","quote_currency":"USD","quote_amount":"1.66608","base_amount":"1.66608"},{"name":"maximum_adjustment","kind":"maximum_adjustment","currency":"USD","amount":"-0.007575","quote_currency":"USD","quote_amount":"-0.007575","base_amount":"-0.007575"}],"cash_interest":"0.285463","settled_cash":"9090.371958","unsettled_cash":"756.575","margin":{"initial_requirement":"132.5","maintenance_requirement":"66.25","initial_excess":"9979.446958","maintenance_excess":"10045.696958","margin_call":false},"group_exposures":[]}} +{"contract_version":"12","engine_sequence":"31","event_id":"demo-event-000000000031","causation_ids":["demo-event-000000000030"],"run_id":"demo","recorded_at":"2026-01-07T21:00:02.000000Z","event_type":"run_completed","payload":{"scenario_sha256":"ee80423182d458afa2458803c30af1d18f0a8d873bbfe4e16c510920a6aee7d3","execution_model":"completed_bar_v1","valuation":{"base_currency":"USD","cash":"9846.946958","net_market_value":"265","long_market_value":"265","short_market_value":"0","gross_exposure":"265","cost_basis":"257.311762","realized_pnl":"19.25872","unrealized_pnl":"7.688238","equity":"10111.946958","dividend_pnl":"1","execution_fees":"2.908505","borrow_fees":"0.25","total_fees":"3.158505","cash_balances":[{"currency":"USD","amount":"9846.946958","fx_rate":"1","base_value":"9846.946958","interest":"0.285463","base_interest":"0.285463","settled_amount":"9090.371958","unsettled_amount":"756.575","base_settled_value":"9090.371958","base_unsettled_value":"756.575"}],"positions":[{"instrument_id":"demo-equity-acme","quote_currency":"USD","quantity":"2.5","mark":"106","fx_rate":"1","market_value":"265","base_market_value":"265","cost_basis":"257.311762","base_cost_basis":"257.311762","realized_pnl":"18.973257","base_realized_pnl":"18.973257","unrealized_pnl":"7.688238","base_unrealized_pnl":"7.688238","dividend_pnl":"1","base_dividend_pnl":"1","execution_fees":"2.908505","base_execution_fees":"2.908505","borrow_fees":"0.25","base_borrow_fees":"0.25","total_fees":"3.158505","base_total_fees":"3.158505","execution_fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"0.75","quote_currency":"USD","quote_amount":"0.75","base_amount":"0.75"},{"name":"exchange","kind":"notional_bps","currency":"USD","amount":"1.66608","quote_currency":"USD","quote_amount":"1.66608","base_amount":"1.66608"},{"name":"maximum_adjustment","kind":"maximum_adjustment","currency":"USD","amount":"-0.007575","quote_currency":"USD","quote_amount":"-0.007575","base_amount":"-0.007575"}],"settled_quantity":"9.715","unsettled_quantity":"-7.215"}],"execution_fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"0.75","quote_currency":"USD","quote_amount":"0.75","base_amount":"0.75"},{"name":"exchange","kind":"notional_bps","currency":"USD","amount":"1.66608","quote_currency":"USD","quote_amount":"1.66608","base_amount":"1.66608"},{"name":"maximum_adjustment","kind":"maximum_adjustment","currency":"USD","amount":"-0.007575","quote_currency":"USD","quote_amount":"-0.007575","base_amount":"-0.007575"}],"cash_interest":"0.285463","settled_cash":"9090.371958","unsettled_cash":"756.575","margin":{"initial_requirement":"132.5","maintenance_requirement":"66.25","initial_excess":"9979.446958","maintenance_excess":"10045.696958","margin_call":false},"group_exposures":[]},"order_counts":{"total":3,"active":0,"filled":2,"rejected":0,"cancelled":1}}} diff --git a/contracts/v12/fixtures/demo.scenario.json b/contracts/v12/fixtures/demo.scenario.json new file mode 100644 index 0000000..cbabef0 --- /dev/null +++ b/contracts/v12/fixtures/demo.scenario.json @@ -0,0 +1,444 @@ +{ + "contract_version": "12", + "metadata": { + "producer": "trading-engine-demo", + "purpose": "deterministic conformance fixture" + }, + "run_id": "demo", + "base_currency": "USD", + "initial_portfolio": { + "cash": [ + { + "currency": "USD", + "amount": "10000" + } + ], + "positions": [ + { + "instrument_id": "demo-equity-acme", + "quantity": "1", + "cost_basis": "90", + "realized_pnl": "5", + "dividend_pnl": "1", + "execution_fees": "0.5", + "borrow_fees": "0.25" + } + ], + "marks": [ + { + "instrument_id": "demo-equity-acme", + "price": "100" + } + ], + "fx_rates": [ + { + "currency": "USD", + "rate": "1" + } + ] + }, + "instruments": [ + { + "instrument_id": "demo-equity-acme", + "symbol": "ACME", + "quote_currency": "USD", + "tick_size": "0.01", + "lot_size": "0.001" + } + ], + "venue_calendars": [ + { + "calendar_id": "demo-xnas-2026", + "calendar_version": "1", + "venue_id": "XNAS", + "instrument_ids": [ + "demo-equity-acme" + ], + "sessions": [ + { + "session_date": "2026-01-01", + "policy": "holiday", + "phases": [] + }, + { + "session_date": "2026-01-02", + "policy": "regular", + "phases": [ + { + "phase": "premarket", + "opens_at": "2026-01-02T09:00:00Z", + "closes_at": "2026-01-02T14:25:00Z" + }, + { + "phase": "opening_auction", + "opens_at": "2026-01-02T14:25:00Z", + "closes_at": "2026-01-02T14:30:00Z" + }, + { + "phase": "regular", + "opens_at": "2026-01-02T14:30:00Z", + "closes_at": "2026-01-02T20:55:00Z" + }, + { + "phase": "closing_auction", + "opens_at": "2026-01-02T20:55:00Z", + "closes_at": "2026-01-02T21:00:00Z" + }, + { + "phase": "postmarket", + "opens_at": "2026-01-02T21:00:00Z", + "closes_at": "2026-01-03T01:00:00Z" + } + ] + }, + { + "session_date": "2026-01-05", + "policy": "regular", + "phases": [ + { + "phase": "regular", + "opens_at": "2026-01-05T14:30:00Z", + "closes_at": "2026-01-05T21:00:00Z" + } + ] + }, + { + "session_date": "2026-01-06", + "policy": "regular", + "phases": [ + { + "phase": "regular", + "opens_at": "2026-01-06T14:30:00Z", + "closes_at": "2026-01-06T21:00:00Z" + } + ] + }, + { + "session_date": "2026-01-07", + "policy": "regular", + "phases": [ + { + "phase": "regular", + "opens_at": "2026-01-07T14:30:00Z", + "closes_at": "2026-01-07T21:00:00Z" + } + ] + }, + { + "session_date": "2026-01-08", + "policy": "early_close", + "phases": [ + { + "phase": "regular", + "opens_at": "2026-01-08T14:30:00Z", + "closes_at": "2026-01-08T18:00:00Z" + } + ] + } + ] + } + ], + "risk": { + "max_gross_exposure": "1000000", + "max_leverage": "2", + "short_borrow_bps": 100, + "instrument_policies": [ + { + "instrument_id": "demo-equity-acme", + "max_order_quantity": "1000", + "max_long_position": "1000", + "max_short_position": "1000", + "max_notional_exposure": "1000000", + "initial_margin_bps": 5000, + "maintenance_margin_bps": 2500, + "shorting_allowed": true + } + ], + "groups": [] + }, + "execution": { + "model": "completed_bar_v1", + "configuration": { + "version": "2", + "participation_bps": 5000, + "fee_schedules": [ + { + "schedule_id": "demo-acme-fees-v1", + "instrument_id": "demo-equity-acme", + "settlement_currency": "USD", + "minimum": "0.3", + "maximum": "1", + "components": [ + { + "name": "broker", + "currency": "USD", + "kind": "fixed", + "value": "0.25", + "rounding": "up", + "applies_to": "any" + }, + { + "name": "exchange", + "currency": "USD", + "kind": "notional_bps", + "value": 10, + "rounding": "up", + "applies_to": "taker" + }, + { + "name": "maker_rebate", + "currency": "USD", + "kind": "notional_bps", + "value": -2, + "rounding": "nearest", + "applies_to": "maker" + } + ] + } + ] + } + }, + "max_internal_events": 1000, + "schedule": [ + { + "after_slice_sequence": "1", + "intents": [ + { + "type": "target_weights", + "targets": [ + { + "instrument_id": "demo-equity-acme", + "weight": "0.1" + } + ] + }, + { + "type": "emit_metric", + "name": "desired_weight", + "value": "0.1" + } + ] + }, + { + "after_slice_sequence": "3", + "intents": [ + { + "type": "target_quantities", + "targets": [ + { + "instrument_id": "demo-equity-acme", + "quantity": "2.5" + } + ] + } + ] + } + ], + "slices": [ + { + "slice_sequence": "1", + "start_at": "2026-01-02T14:30:00Z", + "end_at": "2026-01-02T21:00:00Z", + "available_at": "2026-01-02T21:00:01Z", + "received_at": "2026-01-02T21:00:02Z", + "bars": [ + { + "instrument_id": "demo-equity-acme", + "open": "100", + "high": "105", + "low": "99", + "close": "104", + "volume": "100" + } + ], + "fx_rates": [ + { + "currency": "USD", + "rate": "1" + } + ], + "corporate_actions": [], + "borrow_observations": [ + { + "instrument_id": "demo-equity-acme", + "effective_at": "2026-01-02T14:30:00Z", + "available_quantity": "1000", + "annual_rate_bps": 100, + "recalled": false + } + ], + "cash_rate_observations": [ + { + "currency": "USD", + "effective_at": "2026-01-02T14:30:00Z", + "credit_rate_bps": 100, + "debit_rate_bps": 200 + } + ], + "settlement_failures": [], "lifecycle_events": [] + }, + { + "slice_sequence": "2", + "start_at": "2026-01-05T14:30:00Z", + "end_at": "2026-01-05T21:00:00Z", + "available_at": "2026-01-05T21:00:01Z", + "received_at": "2026-01-05T21:00:02Z", + "bars": [ + { + "instrument_id": "demo-equity-acme", + "open": "103", + "high": "108", + "low": "102", + "close": "107", + "volume": "12" + } + ], + "fx_rates": [ + { + "currency": "USD", + "rate": "1" + } + ], + "corporate_actions": [], + "borrow_observations": [ + { + "instrument_id": "demo-equity-acme", + "effective_at": "2026-01-05T14:30:00Z", + "available_quantity": "1000", + "annual_rate_bps": 100, + "recalled": false + } + ], + "cash_rate_observations": [ + { + "currency": "USD", + "effective_at": "2026-01-05T14:30:00Z", + "credit_rate_bps": 100, + "debit_rate_bps": 200 + } + ], + "settlement_failures": [], "lifecycle_events": [] + }, + { + "slice_sequence": "3", + "start_at": "2026-01-06T14:30:00Z", + "end_at": "2026-01-06T21:00:00Z", + "available_at": "2026-01-06T21:00:01Z", + "received_at": "2026-01-06T21:00:02Z", + "bars": [ + { + "instrument_id": "demo-equity-acme", + "open": "107", + "high": "109", + "low": "104", + "close": "105", + "volume": "100" + } + ], + "fx_rates": [ + { + "currency": "USD", + "rate": "1" + } + ], + "corporate_actions": [], + "borrow_observations": [ + { + "instrument_id": "demo-equity-acme", + "effective_at": "2026-01-06T14:30:00Z", + "available_quantity": "1000", + "annual_rate_bps": 100, + "recalled": false + } + ], + "cash_rate_observations": [ + { + "currency": "USD", + "effective_at": "2026-01-06T14:30:00Z", + "credit_rate_bps": 100, + "debit_rate_bps": 200 + } + ], + "settlement_failures": [], "lifecycle_events": [] + }, + { + "slice_sequence": "4", + "start_at": "2026-01-07T14:30:00Z", + "end_at": "2026-01-07T21:00:00Z", + "available_at": "2026-01-07T21:00:01Z", + "received_at": "2026-01-07T21:00:02Z", + "bars": [ + { + "instrument_id": "demo-equity-acme", + "open": "105", + "high": "107", + "low": "103", + "close": "106", + "volume": "100" + } + ], + "fx_rates": [ + { + "currency": "USD", + "rate": "1" + } + ], + "corporate_actions": [], + "borrow_observations": [ + { + "instrument_id": "demo-equity-acme", + "effective_at": "2026-01-07T14:30:00Z", + "available_quantity": "1000", + "annual_rate_bps": 100, + "recalled": false + } + ], + "cash_rate_observations": [ + { + "currency": "USD", + "effective_at": "2026-01-07T14:30:00Z", + "credit_rate_bps": 100, + "debit_rate_bps": 200 + } + ], + "settlement_failures": [], "lifecycle_events": [] + } + ], + "financing": { + "day_count": "actual_365", + "compounding": "simple", + "borrow_missing_data": "reject", + "cash_missing_data": "reject", + "locate_policy": "clip_fill", + "recall_policy": "close_out" + }, + "settlement": { + "cash_buying_power": "total_cash", + "position_availability": "total_positions", + "calendars": [ + { + "calendar_id": "default-settlement", + "version": "1", + "business_dates": [ + "2026-01-02", + "2026-01-05", + "2026-01-06", + "2026-01-07", + "2026-01-08", + "2026-01-09", + "2026-02-02", + "2026-02-03", + "2026-02-04", + "2026-02-05" + ] + } + ], + "rules": [ + { + "instrument_id": "demo-equity-acme", + "calendar_id": "default-settlement", + "lag_business_days": 1 + } + ] + } +} diff --git a/contracts/v12/fixtures/demo.scenario.jsonl b/contracts/v12/fixtures/demo.scenario.jsonl new file mode 100644 index 0000000..d4e5fbb --- /dev/null +++ b/contracts/v12/fixtures/demo.scenario.jsonl @@ -0,0 +1,6 @@ +{"contract_version":"12","scenario_sequence":"1","record_type":"scenario_header","payload":{"metadata":{"producer":"trading-engine-demo","purpose":"deterministic conformance fixture"},"run_id":"demo","base_currency":"USD","initial_portfolio":{"cash":[{"currency":"USD","amount":"10000"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"1","cost_basis":"90","realized_pnl":"5","dividend_pnl":"1","execution_fees":"0.5","borrow_fees":"0.25"}],"marks":[{"instrument_id":"demo-equity-acme","price":"100"}],"fx_rates":[{"currency":"USD","rate":"1"}]},"instruments":[{"instrument_id":"demo-equity-acme","symbol":"ACME","quote_currency":"USD","tick_size":"0.01","lot_size":"0.001"}],"venue_calendars":[{"calendar_id":"demo-xnas-2026","calendar_version":"1","venue_id":"XNAS","instrument_ids":["demo-equity-acme"],"sessions":[{"session_date":"2026-01-01","policy":"holiday","phases":[]},{"session_date":"2026-01-02","policy":"regular","phases":[{"phase":"premarket","opens_at":"2026-01-02T09:00:00Z","closes_at":"2026-01-02T14:25:00Z"},{"phase":"opening_auction","opens_at":"2026-01-02T14:25:00Z","closes_at":"2026-01-02T14:30:00Z"},{"phase":"regular","opens_at":"2026-01-02T14:30:00Z","closes_at":"2026-01-02T20:55:00Z"},{"phase":"closing_auction","opens_at":"2026-01-02T20:55:00Z","closes_at":"2026-01-02T21:00:00Z"},{"phase":"postmarket","opens_at":"2026-01-02T21:00:00Z","closes_at":"2026-01-03T01:00:00Z"}]},{"session_date":"2026-01-05","policy":"regular","phases":[{"phase":"regular","opens_at":"2026-01-05T14:30:00Z","closes_at":"2026-01-05T21:00:00Z"}]},{"session_date":"2026-01-06","policy":"regular","phases":[{"phase":"regular","opens_at":"2026-01-06T14:30:00Z","closes_at":"2026-01-06T21:00:00Z"}]},{"session_date":"2026-01-07","policy":"regular","phases":[{"phase":"regular","opens_at":"2026-01-07T14:30:00Z","closes_at":"2026-01-07T21:00:00Z"}]},{"session_date":"2026-01-08","policy":"early_close","phases":[{"phase":"regular","opens_at":"2026-01-08T14:30:00Z","closes_at":"2026-01-08T18:00:00Z"}]}]}],"risk":{"max_gross_exposure":"1000000","max_leverage":"2","short_borrow_bps":100,"instrument_policies":[{"instrument_id":"demo-equity-acme","max_order_quantity":"1000","max_long_position":"1000","max_short_position":"1000","max_notional_exposure":"1000000","initial_margin_bps":5000,"maintenance_margin_bps":2500,"shorting_allowed":true}],"groups":[]},"execution":{"model":"completed_bar_v1","configuration":{"version":"2","participation_bps":5000,"fee_schedules":[{"schedule_id":"demo-acme-fees-v1","instrument_id":"demo-equity-acme","settlement_currency":"USD","minimum":"0.3","maximum":"1","components":[{"name":"broker","currency":"USD","kind":"fixed","value":"0.25","rounding":"up","applies_to":"any"},{"name":"exchange","currency":"USD","kind":"notional_bps","value":10,"rounding":"up","applies_to":"taker"},{"name":"maker_rebate","currency":"USD","kind":"notional_bps","value":-2,"rounding":"nearest","applies_to":"maker"}]}]}},"max_internal_events":1000,"financing":{"day_count":"actual_365","compounding":"simple","borrow_missing_data":"reject","cash_missing_data":"reject","locate_policy":"clip_fill","recall_policy":"close_out"},"settlement":{"cash_buying_power":"total_cash","position_availability":"total_positions","calendars":[{"calendar_id":"default-settlement","version":"1","business_dates":["2026-01-02","2026-01-05","2026-01-06","2026-01-07","2026-01-08","2026-01-09","2026-02-02","2026-02-03","2026-02-04","2026-02-05"]}],"rules":[{"instrument_id":"demo-equity-acme","calendar_id":"default-settlement","lag_business_days":1}]}}} +{"contract_version":"12","payload":{"intents":[{"targets":[{"instrument_id":"demo-equity-acme","weight":"0.1"}],"type":"target_weights"},{"name":"desired_weight","type":"emit_metric","value":"0.1"}],"market_slice":{"available_at":"2026-01-02T21:00:01Z","bars":[{"close":"104","high":"105","instrument_id":"demo-equity-acme","low":"99","open":"100","volume":"100"}],"corporate_actions":[],"end_at":"2026-01-02T21:00:00Z","fx_rates":[{"currency":"USD","rate":"1"}],"received_at":"2026-01-02T21:00:02Z","slice_sequence":"1","start_at":"2026-01-02T14:30:00Z","borrow_observations":[{"instrument_id":"demo-equity-acme","effective_at":"2026-01-02T14:30:00Z","available_quantity":"1000","annual_rate_bps":100,"recalled":false}],"cash_rate_observations":[{"currency":"USD","effective_at":"2026-01-02T14:30:00Z","credit_rate_bps":100,"debit_rate_bps":200}],"settlement_failures":[],"lifecycle_events":[]}},"record_type":"market_slice","scenario_sequence":"2"} +{"contract_version":"12","payload":{"intents":[],"market_slice":{"available_at":"2026-01-05T21:00:01Z","bars":[{"close":"107","high":"108","instrument_id":"demo-equity-acme","low":"102","open":"103","volume":"12"}],"corporate_actions":[],"end_at":"2026-01-05T21:00:00Z","fx_rates":[{"currency":"USD","rate":"1"}],"received_at":"2026-01-05T21:00:02Z","slice_sequence":"2","start_at":"2026-01-05T14:30:00Z","borrow_observations":[{"instrument_id":"demo-equity-acme","effective_at":"2026-01-05T14:30:00Z","available_quantity":"1000","annual_rate_bps":100,"recalled":false}],"cash_rate_observations":[{"currency":"USD","effective_at":"2026-01-05T14:30:00Z","credit_rate_bps":100,"debit_rate_bps":200}],"settlement_failures":[],"lifecycle_events":[]}},"record_type":"market_slice","scenario_sequence":"3"} +{"contract_version":"12","payload":{"intents":[{"targets":[{"instrument_id":"demo-equity-acme","quantity":"2.5"}],"type":"target_quantities"}],"market_slice":{"available_at":"2026-01-06T21:00:01Z","bars":[{"close":"105","high":"109","instrument_id":"demo-equity-acme","low":"104","open":"107","volume":"100"}],"corporate_actions":[],"end_at":"2026-01-06T21:00:00Z","fx_rates":[{"currency":"USD","rate":"1"}],"received_at":"2026-01-06T21:00:02Z","slice_sequence":"3","start_at":"2026-01-06T14:30:00Z","borrow_observations":[{"instrument_id":"demo-equity-acme","effective_at":"2026-01-06T14:30:00Z","available_quantity":"1000","annual_rate_bps":100,"recalled":false}],"cash_rate_observations":[{"currency":"USD","effective_at":"2026-01-06T14:30:00Z","credit_rate_bps":100,"debit_rate_bps":200}],"settlement_failures":[],"lifecycle_events":[]}},"record_type":"market_slice","scenario_sequence":"4"} +{"contract_version":"12","payload":{"intents":[],"market_slice":{"available_at":"2026-01-07T21:00:01Z","bars":[{"close":"106","high":"107","instrument_id":"demo-equity-acme","low":"103","open":"105","volume":"100"}],"corporate_actions":[],"end_at":"2026-01-07T21:00:00Z","fx_rates":[{"currency":"USD","rate":"1"}],"received_at":"2026-01-07T21:00:02Z","slice_sequence":"4","start_at":"2026-01-07T14:30:00Z","borrow_observations":[{"instrument_id":"demo-equity-acme","effective_at":"2026-01-07T14:30:00Z","available_quantity":"1000","annual_rate_bps":100,"recalled":false}],"cash_rate_observations":[{"currency":"USD","effective_at":"2026-01-07T14:30:00Z","credit_rate_bps":100,"debit_rate_bps":200}],"settlement_failures":[],"lifecycle_events":[]}},"record_type":"market_slice","scenario_sequence":"5"} +{"contract_version":"12","payload":{"slice_count":"4"},"record_type":"scenario_end","scenario_sequence":"6"} diff --git a/contracts/v12/fixtures/fill-clipped.journal.jsonl b/contracts/v12/fixtures/fill-clipped.journal.jsonl new file mode 100644 index 0000000..168bcc2 --- /dev/null +++ b/contracts/v12/fixtures/fill-clipped.journal.jsonl @@ -0,0 +1,13 @@ +{"contract_version":"12","engine_sequence":"1","event_id":"fill-clipped-event-000000000001","causation_ids":[],"run_id":"fill-clipped","recorded_at":"2026-02-02T21:00:02.000000Z","event_type":"run_started","payload":{"scenario_sha256":"24baff6bd716f87e9bb1aab521527ba056c089390c442e6e650ea193df12abfe","execution_model":"completed_bar_v1"}} +{"contract_version":"12","engine_sequence":"2","event_id":"fill-clipped-event-000000000002","causation_ids":["fill-clipped-event-000000000001"],"run_id":"fill-clipped","recorded_at":"2026-02-02T21:00:02.000000Z","event_type":"initial_state","payload":{"portfolio":{"cash":[{"currency":"USD","amount":"550"}],"positions":[],"marks":[],"fx_rates":[{"currency":"USD","rate":"1"}]},"valuation":{"base_currency":"USD","cash":"550","net_market_value":"0","long_market_value":"0","short_market_value":"0","gross_exposure":"0","cost_basis":"0","realized_pnl":"0","unrealized_pnl":"0","equity":"550","dividend_pnl":"0","execution_fees":"0","borrow_fees":"0","total_fees":"0","cash_balances":[{"currency":"USD","amount":"550","fx_rate":"1","base_value":"550","interest":"0","base_interest":"0","settled_amount":"550","unsettled_amount":"0","base_settled_value":"550","base_unsettled_value":"0"}],"positions":[],"execution_fee_components":[],"cash_interest":"0","settled_cash":"550","unsettled_cash":"0","margin":{"initial_requirement":"0","maintenance_requirement":"0","initial_excess":"550","maintenance_excess":"550","margin_call":false},"group_exposures":[]}}} +{"contract_version":"12","engine_sequence":"3","event_id":"fill-clipped-event-000000000003","causation_ids":["fill-clipped-event-000000000002"],"run_id":"fill-clipped","recorded_at":"2026-02-02T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"550","net_market_value":"0","long_market_value":"0","short_market_value":"0","gross_exposure":"0","cost_basis":"0","realized_pnl":"0","unrealized_pnl":"0","equity":"550","dividend_pnl":"0","execution_fees":"0","borrow_fees":"0","total_fees":"0","cash_balances":[{"currency":"USD","amount":"550","fx_rate":"1","base_value":"550","interest":"0","base_interest":"0","settled_amount":"550","unsettled_amount":"0","base_settled_value":"550","base_unsettled_value":"0"}],"positions":[],"execution_fee_components":[],"cash_interest":"0","settled_cash":"550","unsettled_cash":"0","margin":{"initial_requirement":"0","maintenance_requirement":"0","initial_excess":"550","maintenance_excess":"550","margin_call":false},"group_exposures":[]}} +{"contract_version":"12","engine_sequence":"4","event_id":"fill-clipped-event-000000000004","causation_ids":[],"run_id":"fill-clipped","recorded_at":"2026-02-02T21:00:02.000000Z","event_type":"market_slice_received","payload":{"slice_sequence":"1","start_at":"2026-02-02T14:30:00.000000Z","end_at":"2026-02-02T21:00:00.000000Z","available_at":"2026-02-02T21:00:01.000000Z","received_at":"2026-02-02T21:00:02.000000Z","bars":[{"instrument_id":"clip-equity","open":"50","high":"50","low":"50","close":"50","volume":"100"}],"fx_rates":[{"currency":"USD","rate":"1"}],"corporate_actions":[],"borrow_observations":[{"instrument_id":"clip-equity","effective_at":"2026-02-02T14:30:00.000000Z","available_quantity":"1000","annual_rate_bps":100,"recalled":false}],"cash_rate_observations":[{"currency":"USD","effective_at":"2026-02-02T14:30:00.000000Z","credit_rate_bps":100,"debit_rate_bps":200}],"settlement_failures":[],"lifecycle_events":[]}} +{"contract_version":"12","engine_sequence":"5","event_id":"fill-clipped-event-000000000005","causation_ids":["fill-clipped-event-000000000004"],"run_id":"fill-clipped","recorded_at":"2026-02-02T21:00:02.000000Z","event_type":"cash_interest_applied","payload":{"observation":{"currency":"USD","effective_at":"2026-02-02T14:30:00.000000Z","credit_rate_bps":100,"debit_rate_bps":200},"opening_balance":"550","applied_rate_bps":100,"day_count":"actual_365","compounding":"simple","period_start":"2026-02-02T14:30:00.000000Z","period_end":"2026-02-02T21:00:00.000000Z","amount":"0.004081","closing_balance":"550.004081"}} +{"contract_version":"12","engine_sequence":"6","event_id":"fill-clipped-event-000000000006","causation_ids":["fill-clipped-event-000000000004"],"run_id":"fill-clipped","recorded_at":"2026-02-02T21:00:02.000000Z","event_type":"order_accepted","payload":{"order_id":"fill-clipped-order-000000000001","instrument_id":"clip-equity","side":"buy","quantity":"10","order_kind":"market","trigger_price":null,"limit_price":null,"time_in_force":"ioc","venue_id":null,"calendar_id":null,"expires_at":null,"origin":"direct","created_event_id":"fill-clipped-event-000000000006","updated_event_id":"fill-clipped-event-000000000006","created_sequence":"6","created_at":"2026-02-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","triggered_at":null,"triggered_slice_sequence":null,"filled_quantity":"0","filled_notional":"0","status":"working","rejection_reason":null}} +{"contract_version":"12","engine_sequence":"7","event_id":"fill-clipped-event-000000000007","causation_ids":["fill-clipped-event-000000000004"],"run_id":"fill-clipped","recorded_at":"2026-02-02T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"550.004081","net_market_value":"0","long_market_value":"0","short_market_value":"0","gross_exposure":"0","cost_basis":"0","realized_pnl":"0.004081","unrealized_pnl":"0","equity":"550.004081","dividend_pnl":"0","execution_fees":"0","borrow_fees":"0","total_fees":"0","cash_balances":[{"currency":"USD","amount":"550.004081","fx_rate":"1","base_value":"550.004081","interest":"0.004081","base_interest":"0.004081","settled_amount":"550.004081","unsettled_amount":"0","base_settled_value":"550.004081","base_unsettled_value":"0"}],"positions":[{"instrument_id":"clip-equity","quote_currency":"USD","quantity":"0","mark":"50","fx_rate":"1","market_value":"0","base_market_value":"0","cost_basis":"0","base_cost_basis":"0","realized_pnl":"0","base_realized_pnl":"0","unrealized_pnl":"0","base_unrealized_pnl":"0","dividend_pnl":"0","base_dividend_pnl":"0","execution_fees":"0","base_execution_fees":"0","borrow_fees":"0","base_borrow_fees":"0","total_fees":"0","base_total_fees":"0","execution_fee_components":[],"settled_quantity":"0","unsettled_quantity":"0"}],"execution_fee_components":[],"cash_interest":"0.004081","settled_cash":"550.004081","unsettled_cash":"0","margin":{"initial_requirement":"0","maintenance_requirement":"0","initial_excess":"550.004081","maintenance_excess":"550.004081","margin_call":false},"group_exposures":[]}} +{"contract_version":"12","engine_sequence":"8","event_id":"fill-clipped-event-000000000008","causation_ids":[],"run_id":"fill-clipped","recorded_at":"2026-02-03T21:00:02.000000Z","event_type":"market_slice_received","payload":{"slice_sequence":"2","start_at":"2026-02-03T14:30:00.000000Z","end_at":"2026-02-03T21:00:00.000000Z","available_at":"2026-02-03T21:00:01.000000Z","received_at":"2026-02-03T21:00:02.000000Z","bars":[{"instrument_id":"clip-equity","open":"100","high":"100","low":"100","close":"100","volume":"100"}],"fx_rates":[{"currency":"USD","rate":"1"}],"corporate_actions":[],"borrow_observations":[{"instrument_id":"clip-equity","effective_at":"2026-02-03T14:30:00.000000Z","available_quantity":"1000","annual_rate_bps":100,"recalled":false}],"cash_rate_observations":[{"currency":"USD","effective_at":"2026-02-03T14:30:00.000000Z","credit_rate_bps":100,"debit_rate_bps":200}],"settlement_failures":[],"lifecycle_events":[]}} +{"contract_version":"12","engine_sequence":"9","event_id":"fill-clipped-event-000000000009","causation_ids":["fill-clipped-event-000000000008"],"run_id":"fill-clipped","recorded_at":"2026-02-03T21:00:02.000000Z","event_type":"cash_interest_applied","payload":{"observation":{"currency":"USD","effective_at":"2026-02-03T14:30:00.000000Z","credit_rate_bps":100,"debit_rate_bps":200},"opening_balance":"550.004081","applied_rate_bps":100,"day_count":"actual_365","compounding":"simple","period_start":"2026-02-03T14:30:00.000000Z","period_end":"2026-02-03T21:00:00.000000Z","amount":"0.004081","closing_balance":"550.008162"}} +{"contract_version":"12","engine_sequence":"10","event_id":"fill-clipped-event-000000000010","causation_ids":["fill-clipped-event-000000000006","fill-clipped-event-000000000008"],"run_id":"fill-clipped","recorded_at":"2026-02-03T21:00:02.000000Z","event_type":"fill_clipped","payload":{"reason":{"version":"1","policy":"max_leverage","threshold":{"unit":"ratio","value":"1"}},"order_id":"fill-clipped-order-000000000001","instrument_id":"clip-equity","proposed_quantity":"10","permitted_quantity":"0","price":"100"}} +{"contract_version":"12","engine_sequence":"11","event_id":"fill-clipped-event-000000000011","causation_ids":["fill-clipped-event-000000000006","fill-clipped-event-000000000008"],"run_id":"fill-clipped","recorded_at":"2026-02-03T21:00:02.000000Z","event_type":"order_cancelled","payload":{"order":{"order_id":"fill-clipped-order-000000000001","instrument_id":"clip-equity","side":"buy","quantity":"10","order_kind":"market","trigger_price":null,"limit_price":null,"time_in_force":"ioc","venue_id":null,"calendar_id":null,"expires_at":null,"origin":"direct","created_event_id":"fill-clipped-event-000000000006","updated_event_id":"fill-clipped-event-000000000006","created_sequence":"6","created_at":"2026-02-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","triggered_at":null,"triggered_slice_sequence":null,"filled_quantity":"0","filled_notional":"0","status":"cancelled","rejection_reason":null},"reason":"market_ioc"}} +{"contract_version":"12","engine_sequence":"12","event_id":"fill-clipped-event-000000000012","causation_ids":["fill-clipped-event-000000000008"],"run_id":"fill-clipped","recorded_at":"2026-02-03T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"550.008162","net_market_value":"0","long_market_value":"0","short_market_value":"0","gross_exposure":"0","cost_basis":"0","realized_pnl":"0.008162","unrealized_pnl":"0","equity":"550.008162","dividend_pnl":"0","execution_fees":"0","borrow_fees":"0","total_fees":"0","cash_balances":[{"currency":"USD","amount":"550.008162","fx_rate":"1","base_value":"550.008162","interest":"0.008162","base_interest":"0.008162","settled_amount":"550.008162","unsettled_amount":"0","base_settled_value":"550.008162","base_unsettled_value":"0"}],"positions":[{"instrument_id":"clip-equity","quote_currency":"USD","quantity":"0","mark":"100","fx_rate":"1","market_value":"0","base_market_value":"0","cost_basis":"0","base_cost_basis":"0","realized_pnl":"0","base_realized_pnl":"0","unrealized_pnl":"0","base_unrealized_pnl":"0","dividend_pnl":"0","base_dividend_pnl":"0","execution_fees":"0","base_execution_fees":"0","borrow_fees":"0","base_borrow_fees":"0","total_fees":"0","base_total_fees":"0","execution_fee_components":[],"settled_quantity":"0","unsettled_quantity":"0"}],"execution_fee_components":[],"cash_interest":"0.008162","settled_cash":"550.008162","unsettled_cash":"0","margin":{"initial_requirement":"0","maintenance_requirement":"0","initial_excess":"550.008162","maintenance_excess":"550.008162","margin_call":false},"group_exposures":[]}} +{"contract_version":"12","engine_sequence":"13","event_id":"fill-clipped-event-000000000013","causation_ids":["fill-clipped-event-000000000012"],"run_id":"fill-clipped","recorded_at":"2026-02-03T21:00:02.000000Z","event_type":"run_completed","payload":{"scenario_sha256":"24baff6bd716f87e9bb1aab521527ba056c089390c442e6e650ea193df12abfe","execution_model":"completed_bar_v1","valuation":{"base_currency":"USD","cash":"550.008162","net_market_value":"0","long_market_value":"0","short_market_value":"0","gross_exposure":"0","cost_basis":"0","realized_pnl":"0.008162","unrealized_pnl":"0","equity":"550.008162","dividend_pnl":"0","execution_fees":"0","borrow_fees":"0","total_fees":"0","cash_balances":[{"currency":"USD","amount":"550.008162","fx_rate":"1","base_value":"550.008162","interest":"0.008162","base_interest":"0.008162","settled_amount":"550.008162","unsettled_amount":"0","base_settled_value":"550.008162","base_unsettled_value":"0"}],"positions":[{"instrument_id":"clip-equity","quote_currency":"USD","quantity":"0","mark":"100","fx_rate":"1","market_value":"0","base_market_value":"0","cost_basis":"0","base_cost_basis":"0","realized_pnl":"0","base_realized_pnl":"0","unrealized_pnl":"0","base_unrealized_pnl":"0","dividend_pnl":"0","base_dividend_pnl":"0","execution_fees":"0","base_execution_fees":"0","borrow_fees":"0","base_borrow_fees":"0","total_fees":"0","base_total_fees":"0","execution_fee_components":[],"settled_quantity":"0","unsettled_quantity":"0"}],"execution_fee_components":[],"cash_interest":"0.008162","settled_cash":"550.008162","unsettled_cash":"0","margin":{"initial_requirement":"0","maintenance_requirement":"0","initial_excess":"550.008162","maintenance_excess":"550.008162","margin_call":false},"group_exposures":[]},"order_counts":{"total":1,"active":0,"filled":0,"rejected":0,"cancelled":1}}} diff --git a/contracts/v12/fixtures/fill-clipped.scenario.json b/contracts/v12/fixtures/fill-clipped.scenario.json new file mode 100644 index 0000000..ff34f55 --- /dev/null +++ b/contracts/v12/fixtures/fill-clipped.scenario.json @@ -0,0 +1,267 @@ +{ + "contract_version": "12", + "metadata": { + "producer": "trading-engine", + "purpose": "fill clipping conformance fixture" + }, + "run_id": "fill-clipped", + "base_currency": "USD", + "initial_portfolio": { + "cash": [ + { + "currency": "USD", + "amount": "550" + } + ], + "positions": [], + "marks": [], + "fx_rates": [ + { + "currency": "USD", + "rate": "1" + } + ] + }, + "instruments": [ + { + "instrument_id": "clip-equity", + "symbol": "CLIP", + "quote_currency": "USD", + "tick_size": "0.01", + "lot_size": "1" + } + ], + "venue_calendars": [ + { + "calendar_id": "clip-xnas-2026", + "calendar_version": "1", + "venue_id": "XNAS", + "instrument_ids": [ + "clip-equity" + ], + "sessions": [ + { + "session_date": "2026-02-02", + "policy": "regular", + "phases": [ + { + "phase": "regular", + "opens_at": "2026-02-02T14:30:00Z", + "closes_at": "2026-02-02T21:00:00Z" + } + ] + }, + { + "session_date": "2026-02-03", + "policy": "regular", + "phases": [ + { + "phase": "regular", + "opens_at": "2026-02-03T14:30:00Z", + "closes_at": "2026-02-03T21:00:00Z" + } + ] + }, + { + "session_date": "2026-02-04", + "policy": "early_close", + "phases": [ + { + "phase": "regular", + "opens_at": "2026-02-04T14:30:00Z", + "closes_at": "2026-02-04T18:00:00Z" + } + ] + } + ] + } + ], + "risk": { + "max_gross_exposure": "1000000000", + "max_leverage": "1", + "short_borrow_bps": 100, + "instrument_policies": [ + { + "instrument_id": "clip-equity", + "max_order_quantity": "1000", + "max_long_position": "1000", + "max_short_position": "1000", + "max_notional_exposure": "1000000000", + "initial_margin_bps": 5000, + "maintenance_margin_bps": 2500, + "shorting_allowed": true + } + ], + "groups": [] + }, + "execution": { + "model": "completed_bar_v1", + "configuration": { + "version": "2", + "participation_bps": 10000, + "fee_schedules": [ + { + "schedule_id": "clip-fees-v1", + "instrument_id": "clip-equity", + "settlement_currency": "USD", + "minimum": null, + "maximum": null, + "components": [ + { + "name": "broker", + "currency": "USD", + "kind": "fixed", + "value": "10", + "rounding": "up", + "applies_to": "any" + } + ] + } + ] + } + }, + "max_internal_events": 1000, + "schedule": [ + { + "after_slice_sequence": "1", + "intents": [ + { + "type": "submit_order", + "instrument_id": "clip-equity", + "side": "buy", + "quantity": "10", + "order_kind": "market", + "trigger_price": null, + "limit_price": null, + "time_in_force": "ioc", + "venue_id": null, + "calendar_id": null, + "expires_at": null + } + ] + } + ], + "slices": [ + { + "slice_sequence": "1", + "start_at": "2026-02-02T14:30:00Z", + "end_at": "2026-02-02T21:00:00Z", + "available_at": "2026-02-02T21:00:01Z", + "received_at": "2026-02-02T21:00:02Z", + "bars": [ + { + "instrument_id": "clip-equity", + "open": "50", + "high": "50", + "low": "50", + "close": "50", + "volume": "100" + } + ], + "fx_rates": [ + { + "currency": "USD", + "rate": "1" + } + ], + "corporate_actions": [], + "borrow_observations": [ + { + "instrument_id": "clip-equity", + "effective_at": "2026-02-02T14:30:00Z", + "available_quantity": "1000", + "annual_rate_bps": 100, + "recalled": false + } + ], + "cash_rate_observations": [ + { + "currency": "USD", + "effective_at": "2026-02-02T14:30:00Z", + "credit_rate_bps": 100, + "debit_rate_bps": 200 + } + ], + "settlement_failures": [], "lifecycle_events": [] + }, + { + "slice_sequence": "2", + "start_at": "2026-02-03T14:30:00Z", + "end_at": "2026-02-03T21:00:00Z", + "available_at": "2026-02-03T21:00:01Z", + "received_at": "2026-02-03T21:00:02Z", + "bars": [ + { + "instrument_id": "clip-equity", + "open": "100", + "high": "100", + "low": "100", + "close": "100", + "volume": "100" + } + ], + "fx_rates": [ + { + "currency": "USD", + "rate": "1" + } + ], + "corporate_actions": [], + "borrow_observations": [ + { + "instrument_id": "clip-equity", + "effective_at": "2026-02-03T14:30:00Z", + "available_quantity": "1000", + "annual_rate_bps": 100, + "recalled": false + } + ], + "cash_rate_observations": [ + { + "currency": "USD", + "effective_at": "2026-02-03T14:30:00Z", + "credit_rate_bps": 100, + "debit_rate_bps": 200 + } + ], + "settlement_failures": [], "lifecycle_events": [] + } + ], + "financing": { + "day_count": "actual_365", + "compounding": "simple", + "borrow_missing_data": "reject", + "cash_missing_data": "reject", + "locate_policy": "clip_fill", + "recall_policy": "close_out" + }, + "settlement": { + "cash_buying_power": "total_cash", + "position_availability": "total_positions", + "calendars": [ + { + "calendar_id": "default-settlement", + "version": "1", + "business_dates": [ + "2026-01-02", + "2026-01-05", + "2026-01-06", + "2026-01-07", + "2026-01-08", + "2026-01-09", + "2026-02-02", + "2026-02-03", + "2026-02-04", + "2026-02-05" + ] + } + ], + "rules": [ + { + "instrument_id": "clip-equity", + "calendar_id": "default-settlement", + "lag_business_days": 1 + } + ] + } +} diff --git a/contracts/v12/journal.schema.json b/contracts/v12/journal.schema.json new file mode 100644 index 0000000..210f8e3 --- /dev/null +++ b/contracts/v12/journal.schema.json @@ -0,0 +1,2394 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/fallblu/trading-engine/contracts/v12/journal.schema.json", + "title": "Trading Engine v12 audit journal record", + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "engine_sequence", + "event_id", + "causation_ids", + "run_id", + "recorded_at", + "event_type", + "payload" + ], + "properties": { + "contract_version": { + "const": "12" + }, + "engine_sequence": { + "$ref": "#/$defs/sequence" + }, + "event_id": { + "$ref": "#/$defs/identifier" + }, + "causation_ids": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/identifier" + } + }, + "run_id": { + "$ref": "#/$defs/identifier" + }, + "recorded_at": { + "$ref": "#/$defs/timestamp" + }, + "event_type": { + "enum": [ + "run_started", + "initial_state", + "market_slice_received", + "target_portfolio_requested", + "order_accepted", + "order_rejected", + "order_triggered", + "order_cancelled", + "split_applied", + "cash_dividend_applied", + "distribution_applied", + "lifecycle_applied", + "order_adjusted", + "fill_applied", + "settlement_instruction_created", + "settlement_completed", + "settlement_failed", + "fill_clipped", + "borrow_fee_applied", + "borrow_charge_applied", + "borrow_recall_received", + "cash_interest_applied", + "margin_call", + "margin_restored", + "intent_rejected", + "metric_emitted", + "valuation", + "run_completed" + ] + }, + "payload": { + "type": "object" + } + }, + "allOf": [ + { + "if": { + "properties": { + "event_type": { + "const": "run_started" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/runStarted" + } + } + } + }, + { + "if": { + "properties": { + "event_type": { + "const": "initial_state" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/initialState" + } + } + } + }, + { + "if": { + "properties": { + "event_type": { + "const": "market_slice_received" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/marketSlice" + } + } + } + }, + { + "if": { + "properties": { + "event_type": { + "const": "target_portfolio_requested" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/targetPortfolio" + } + } + } + }, + { + "if": { + "properties": { + "event_type": { + "enum": [ + "order_accepted", + "order_rejected", + "order_triggered" + ] + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/order" + } + } + } + }, + { + "if": { + "properties": { + "event_type": { + "const": "order_cancelled" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/orderCancelled" + } + } + } + }, + { + "if": { + "properties": { + "event_type": { + "const": "split_applied" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/splitApplied" + } + } + } + }, + { + "if": { + "properties": { + "event_type": { + "const": "cash_dividend_applied" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/dividendApplied" + } + } + } + }, + { + "if": { "properties": { "event_type": { "const": "distribution_applied" } } }, + "then": { "properties": { "payload": { "$ref": "#/$defs/distributionApplied" } } } + }, + { + "if": { "properties": { "event_type": { "const": "lifecycle_applied" } } }, + "then": { "properties": { "payload": { "$ref": "#/$defs/lifecycleApplied" } } } + }, + { + "if": { + "properties": { + "event_type": { + "const": "order_adjusted" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/orderAdjusted" + } + } + } + }, + { + "if": { + "properties": { + "event_type": { + "const": "fill_applied" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/fill" + } + } + } + }, + { + "if": { + "properties": { + "event_type": { + "enum": [ + "settlement_instruction_created", + "settlement_completed", + "settlement_failed" + ] + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/settlementInstruction" + } + } + } + }, + { + "if": { + "properties": { + "event_type": { + "const": "fill_clipped" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/fillClipped" + } + } + } + }, + { + "if": { + "properties": { + "event_type": { + "const": "borrow_fee_applied" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/borrowFee" + } + } + } + }, + { + "if": { + "properties": { + "event_type": { + "const": "borrow_charge_applied" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/borrowCharge" + } + } + } + }, + { + "if": { + "properties": { + "event_type": { + "const": "borrow_recall_received" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/borrowRecall" + } + } + } + }, + { + "if": { + "properties": { + "event_type": { + "const": "cash_interest_applied" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/cashInterest" + } + } + } + }, + { + "if": { + "properties": { + "event_type": { + "enum": [ + "margin_call", + "margin_restored", + "valuation" + ] + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/valuation" + } + } + } + }, + { + "if": { + "properties": { + "event_type": { + "const": "intent_rejected" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/intentRejected" + } + } + } + }, + { + "if": { + "properties": { + "event_type": { + "const": "metric_emitted" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/metric" + } + } + } + }, + { + "if": { + "properties": { + "event_type": { + "const": "run_completed" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/runCompleted" + } + } + } + } + ], + "$defs": { + "identifier": { + "type": "string", + "minLength": 1, + "pattern": "^[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "signedDecimal": { + "type": "string", + "pattern": "^(?:0|-?(?:0[.][0-9]{0,5}[1-9]|[1-9][0-9]*(?:[.][0-9]{0,5}[1-9])?))$" + }, + "unsignedDecimal": { + "type": "string", + "pattern": "^(?:0|0[.][0-9]{0,5}[1-9]|[1-9][0-9]*(?:[.][0-9]{0,5}[1-9])?)$" + }, + "positiveDecimal": { + "type": "string", + "pattern": "^(?:0[.][0-9]{0,5}[1-9]|[1-9][0-9]*(?:[.][0-9]{0,5}[1-9])?)$" + }, + "sequence": { + "type": "string", + "pattern": "^[1-9][0-9]*$" + }, + "nonnegativeSequence": { + "type": "string", + "pattern": "^(?:0|[1-9][0-9]*)$" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:[0-9]{2}:[0-5][0-9](?:[.][0-9]{1,6})?(?:[zZ]|[+-][0-9]{2}:[0-9]{2})$" + }, + "runStarted": { + "type": "object", + "additionalProperties": false, + "required": [ + "scenario_sha256", + "execution_model" + ], + "properties": { + "scenario_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "execution_model": { + "const": "completed_bar_v1" + } + } + }, + "initialState": { + "type": "object", + "additionalProperties": false, + "required": [ + "portfolio", + "valuation" + ], + "properties": { + "portfolio": { + "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/initialPortfolio" + }, + "valuation": { + "$ref": "#/$defs/valuation" + } + } + }, + "bar": { + "type": "object", + "additionalProperties": false, + "required": [ + "instrument_id", + "open", + "high", + "low", + "close", + "volume" + ], + "properties": { + "instrument_id": { + "$ref": "#/$defs/identifier" + }, + "open": { + "$ref": "#/$defs/positiveDecimal" + }, + "high": { + "$ref": "#/$defs/positiveDecimal" + }, + "low": { + "$ref": "#/$defs/positiveDecimal" + }, + "close": { + "$ref": "#/$defs/positiveDecimal" + }, + "volume": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/unsignedDecimal" + } + ] + } + } + }, + "fxRate": { + "type": "object", + "additionalProperties": false, + "required": [ + "currency", + "rate" + ], + "properties": { + "currency": { + "$ref": "#/$defs/identifier" + }, + "rate": { + "$ref": "#/$defs/positiveDecimal" + } + } + }, + "corporateAction": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "action_id", + "instrument_id", + "numerator", + "denominator" + ], + "properties": { + "type": { + "const": "split" + }, + "action_id": { + "$ref": "#/$defs/identifier" + }, + "instrument_id": { + "$ref": "#/$defs/identifier" + }, + "numerator": { + "$ref": "#/$defs/sequence" + }, + "denominator": { + "$ref": "#/$defs/sequence" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "action_id", + "instrument_id", + "amount_per_unit" + ], + "properties": { + "type": { + "const": "cash_dividend" + }, + "action_id": { + "$ref": "#/$defs/identifier" + }, + "instrument_id": { + "$ref": "#/$defs/identifier" + }, + "amount_per_unit": { + "$ref": "#/$defs/positiveDecimal" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "action_id", "instrument_id", "destination_instrument_id", "numerator", "denominator", "basis_allocation_bps", "fractional_policy"], + "properties": { + "type": { "enum": ["stock_dividend", "rights", "spin_off"] }, + "action_id": { "$ref": "#/$defs/identifier" }, + "instrument_id": { "$ref": "#/$defs/identifier" }, + "destination_instrument_id": { "$ref": "#/$defs/identifier" }, + "numerator": { "$ref": "#/$defs/sequence" }, + "denominator": { "$ref": "#/$defs/sequence" }, + "basis_allocation_bps": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "fractional_policy": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/fractionalPolicy" } + } + } + ] + }, + "marketSlice": { + "type": "object", + "additionalProperties": false, + "required": [ + "slice_sequence", + "start_at", + "end_at", + "available_at", + "received_at", + "bars", + "fx_rates", + "corporate_actions", + "borrow_observations", + "cash_rate_observations", + "settlement_failures", + "lifecycle_events" + ], + "properties": { + "slice_sequence": { + "$ref": "#/$defs/sequence" + }, + "start_at": { + "$ref": "#/$defs/timestamp" + }, + "end_at": { + "$ref": "#/$defs/timestamp" + }, + "available_at": { + "$ref": "#/$defs/timestamp" + }, + "received_at": { + "$ref": "#/$defs/timestamp" + }, + "bars": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/bar" + } + }, + "fx_rates": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/fxRate" + } + }, + "corporate_actions": { + "type": "array", + "items": { + "$ref": "#/$defs/corporateAction" + } + }, + "borrow_observations": { + "type": "array", + "items": { + "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/borrowObservation" + } + }, + "cash_rate_observations": { + "type": "array", + "items": { + "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/cashRateObservation" + } + }, + "settlement_failures": { + "type": "array", + "items": { + "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/settlementFailure" + } + }, + "lifecycle_events": { + "type": "array", + "items": { + "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/lifecycleEvent" + } + } + } + }, + "settlementInstruction": { + "type": "object", + "additionalProperties": false, + "required": ["instruction_id", "fill_id", "instrument_id", "currency", "cash_movement", "position_movement", "trade_date", "due_date", "status", "settled_at", "failed_at", "failure_reason"], + "properties": { + "instruction_id": { "$ref": "#/$defs/identifier" }, + "fill_id": { "$ref": "#/$defs/identifier" }, + "instrument_id": { "$ref": "#/$defs/identifier" }, + "currency": { "$ref": "#/$defs/identifier" }, + "cash_movement": { "$ref": "#/$defs/signedDecimal" }, + "position_movement": { "$ref": "#/$defs/signedDecimal" }, + "trade_date": { "type": "string", "format": "date", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }, + "due_date": { "type": "string", "format": "date", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }, + "status": { "enum": ["pending", "settled", "failed"] }, + "settled_at": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/timestamp" }] }, + "failed_at": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/timestamp" }] }, + "failure_reason": { "oneOf": [{ "type": "null" }, { "type": "string", "minLength": 1, "pattern": "^\\S(?:.*\\S)?$" }] } + } + }, + "targetPortfolio": { + "type": "object", + "additionalProperties": false, + "required": [ + "basis", + "targets" + ], + "properties": { + "basis": { + "enum": [ + "weights", + "quantities" + ] + }, + "targets": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "instrument_id", + "weight", + "quantity", + "reference_price" + ], + "properties": { + "instrument_id": { + "$ref": "#/$defs/identifier" + }, + "weight": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/signedDecimal" + } + ] + }, + "quantity": { + "$ref": "#/$defs/signedDecimal" + }, + "reference_price": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/positiveDecimal" + } + ] + } + } + } + } + } + }, + "order": { + "type": "object", + "additionalProperties": false, + "required": [ + "order_id", + "instrument_id", + "side", + "quantity", + "order_kind", + "trigger_price", + "limit_price", + "time_in_force", + "venue_id", + "calendar_id", + "expires_at", + "origin", + "created_event_id", + "updated_event_id", + "created_sequence", + "created_at", + "eligible_after_slice_sequence", + "triggered_at", + "triggered_slice_sequence", + "filled_quantity", + "filled_notional", + "status", + "rejection_reason" + ], + "properties": { + "order_id": { + "$ref": "#/$defs/identifier" + }, + "instrument_id": { + "$ref": "#/$defs/identifier" + }, + "side": { + "enum": [ + "buy", + "sell" + ] + }, + "quantity": { + "$ref": "#/$defs/positiveDecimal" + }, + "order_kind": { + "enum": [ + "market", + "limit", + "stop", + "stop_limit" + ] + }, + "trigger_price": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/positiveDecimal" + } + ] + }, + "limit_price": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/positiveDecimal" + } + ] + }, + "time_in_force": { + "enum": [ + "gtc", + "ioc", + "fok", + "day", + "gtd" + ] + }, + "venue_id": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/identifier" + } + ] + }, + "calendar_id": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/identifier" + } + ] + }, + "expires_at": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/timestamp" + } + ] + }, + "origin": { + "enum": [ + "direct", + "target_rebalance", + "margin_liquidation", + "borrow_recall", + "instrument_halt", + "instrument_terminal" + ] + }, + "created_event_id": { + "$ref": "#/$defs/identifier" + }, + "updated_event_id": { + "$ref": "#/$defs/identifier" + }, + "created_sequence": { + "$ref": "#/$defs/sequence" + }, + "created_at": { + "$ref": "#/$defs/timestamp" + }, + "eligible_after_slice_sequence": { + "$ref": "#/$defs/nonnegativeSequence" + }, + "triggered_at": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/timestamp" + } + ] + }, + "triggered_slice_sequence": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/sequence" + } + ] + }, + "filled_quantity": { + "$ref": "#/$defs/unsignedDecimal" + }, + "filled_notional": { + "$ref": "#/$defs/unsignedDecimal" + }, + "status": { + "enum": [ + "working", + "partially_filled", + "filled", + "cancelled", + "rejected" + ] + }, + "rejection_reason": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string", + "minLength": 1 + } + ] + } + } + }, + "orderCancelled": { + "type": "object", + "additionalProperties": false, + "required": [ + "order", + "reason" + ], + "properties": { + "order": { + "$ref": "#/$defs/order" + }, + "reason": { + "enum": [ + "strategy_requested", + "target_replaced", + "market_ioc", + "immediate_or_cancel", + "fill_or_kill", + "day_expired", + "gtd_expired", + "margin_call", + "borrow_recall" + ] + } + } + }, + "splitApplied": { + "type": "object", + "additionalProperties": false, + "required": [ + "action", + "previous_quantity", + "adjusted_quantity" + ], + "properties": { + "action": { + "$ref": "#/$defs/corporateAction" + }, + "previous_quantity": { + "$ref": "#/$defs/signedDecimal" + }, + "adjusted_quantity": { + "$ref": "#/$defs/signedDecimal" + } + } + }, + "dividendApplied": { + "type": "object", + "additionalProperties": false, + "required": [ + "action", + "quantity", + "cash_amount" + ], + "properties": { + "action": { + "$ref": "#/$defs/corporateAction" + }, + "quantity": { + "$ref": "#/$defs/signedDecimal" + }, + "cash_amount": { + "$ref": "#/$defs/signedDecimal" + } + } + }, + "distributionApplied": { + "type": "object", + "additionalProperties": false, + "required": ["action", "source_quantity", "destination_quantity", "fractional_quantity", "allocated_basis", "fractional_basis", "cash_in_lieu"], + "properties": { + "action": { "$ref": "#/$defs/corporateAction" }, + "source_quantity": { "$ref": "#/$defs/signedDecimal" }, + "destination_quantity": { "$ref": "#/$defs/signedDecimal" }, + "fractional_quantity": { "$ref": "#/$defs/signedDecimal" }, + "allocated_basis": { "$ref": "#/$defs/signedDecimal" }, + "fractional_basis": { "$ref": "#/$defs/signedDecimal" }, + "cash_in_lieu": { "$ref": "#/$defs/signedDecimal" } + } + }, + "lifecycleApplied": { + "type": "object", + "additionalProperties": false, + "required": ["lifecycle_event", "listing", "liquidated_quantity", "cash_amount"], + "properties": { + "lifecycle_event": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/lifecycleEvent" }, + "listing": { + "type": "object", + "additionalProperties": false, + "required": ["instrument_id", "symbol", "status", "provider_mappings"], + "properties": { + "instrument_id": { "$ref": "#/$defs/identifier" }, + "symbol": { "$ref": "#/$defs/identifier" }, + "status": { "enum": ["tradable", "halted", "expired", "delisted"] }, + "provider_mappings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["provider", "provider_instrument_id"], + "properties": { + "provider": { "$ref": "#/$defs/identifier" }, + "provider_instrument_id": { "$ref": "#/$defs/identifier" } + } + } + } + } + }, + "liquidated_quantity": { "$ref": "#/$defs/signedDecimal" }, + "cash_amount": { "$ref": "#/$defs/signedDecimal" } + } + }, + "orderAdjusted": { + "type": "object", + "additionalProperties": false, + "required": [ + "order", + "action_id" + ], + "properties": { + "order": { + "$ref": "#/$defs/order" + }, + "action_id": { + "$ref": "#/$defs/identifier" + } + } + }, + "fill": { + "type": "object", + "additionalProperties": false, + "required": [ + "fill_id", + "order_id", + "instrument_id", + "quote_currency", + "side", + "quantity", + "price", + "notional", + "fee", + "executed_at", + "slice_sequence", + "fee_components" + ], + "properties": { + "fill_id": { + "$ref": "#/$defs/identifier" + }, + "order_id": { + "$ref": "#/$defs/identifier" + }, + "instrument_id": { + "$ref": "#/$defs/identifier" + }, + "quote_currency": { + "$ref": "#/$defs/identifier" + }, + "side": { + "enum": [ + "buy", + "sell" + ] + }, + "quantity": { + "$ref": "#/$defs/positiveDecimal" + }, + "price": { + "$ref": "#/$defs/positiveDecimal" + }, + "notional": { + "$ref": "#/$defs/positiveDecimal" + }, + "fee": { + "$ref": "#/$defs/signedDecimal" + }, + "fee_components": { + "type": "array", + "items": { + "$ref": "#/$defs/calculatedFeeComponent" + } + }, + "executed_at": { + "$ref": "#/$defs/timestamp" + }, + "slice_sequence": { + "$ref": "#/$defs/sequence" + } + } + }, + "calculatedFeeComponent": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "kind", + "currency", + "amount", + "quote_amount" + ], + "properties": { + "name": { + "$ref": "#/$defs/identifier" + }, + "kind": { + "enum": [ + "fixed", + "notional_bps", + "per_unit", + "minimum_adjustment", + "maximum_adjustment" + ] + }, + "currency": { + "$ref": "#/$defs/identifier" + }, + "amount": { + "$ref": "#/$defs/signedDecimal" + }, + "quote_amount": { + "$ref": "#/$defs/signedDecimal" + } + } + }, + "feeComponentAttribution": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "kind", + "currency", + "amount", + "quote_currency", + "quote_amount", + "base_amount" + ], + "properties": { + "name": { + "$ref": "#/$defs/identifier" + }, + "kind": { + "enum": [ + "fixed", + "notional_bps", + "per_unit", + "minimum_adjustment", + "maximum_adjustment" + ] + }, + "currency": { + "$ref": "#/$defs/identifier" + }, + "amount": { + "$ref": "#/$defs/signedDecimal" + }, + "quote_currency": { + "$ref": "#/$defs/identifier" + }, + "quote_amount": { + "$ref": "#/$defs/signedDecimal" + }, + "base_amount": { + "$ref": "#/$defs/signedDecimal" + } + } + }, + "quantityThreshold": { + "type": "object", + "additionalProperties": false, + "required": [ + "unit", + "value" + ], + "properties": { + "unit": { + "const": "quantity" + }, + "value": { + "$ref": "#/$defs/positiveDecimal" + } + } + }, + "moneyThreshold": { + "type": "object", + "additionalProperties": false, + "required": [ + "unit", + "value" + ], + "properties": { + "unit": { + "const": "money" + }, + "value": { + "$ref": "#/$defs/positiveDecimal" + } + } + }, + "ratioThreshold": { + "type": "object", + "additionalProperties": false, + "required": [ + "unit", + "value" + ], + "properties": { + "unit": { + "const": "ratio" + }, + "value": { + "$ref": "#/$defs/positiveDecimal" + } + } + }, + "basisPointsThreshold": { + "type": "object", + "additionalProperties": false, + "required": [ + "unit", + "value" + ], + "properties": { + "unit": { + "const": "basis_points" + }, + "value": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + } + } + }, + "instrumentQuantityThreshold": { + "type": "object", + "additionalProperties": false, + "required": [ + "instrument_id", + "unit", + "value" + ], + "properties": { + "instrument_id": { + "$ref": "#/$defs/identifier" + }, + "unit": { + "const": "quantity" + }, + "value": { + "$ref": "#/$defs/positiveDecimal" + } + } + }, + "instrumentMoneyThreshold": { + "type": "object", + "additionalProperties": false, + "required": [ + "instrument_id", + "unit", + "value" + ], + "properties": { + "instrument_id": { + "$ref": "#/$defs/identifier" + }, + "unit": { + "const": "money" + }, + "value": { + "$ref": "#/$defs/positiveDecimal" + } + } + }, + "currencyMoneyThreshold": { + "type": "object", + "additionalProperties": false, + "required": ["currency", "unit", "value"], + "properties": { + "currency": { "$ref": "#/$defs/identifier" }, + "unit": { "const": "money" }, + "value": { "$ref": "#/$defs/unsignedDecimal" } + } + }, + "settlementPositionThreshold": { + "type": "object", + "additionalProperties": false, + "required": ["instrument_id", "unit", "value"], + "properties": { + "instrument_id": { "$ref": "#/$defs/identifier" }, + "unit": { "const": "quantity" }, + "value": { "$ref": "#/$defs/unsignedDecimal" } + } + }, + "instrumentBasisPointsThreshold": { + "type": "object", + "additionalProperties": false, + "required": [ + "instrument_id", + "unit", + "value" + ], + "properties": { + "instrument_id": { + "$ref": "#/$defs/identifier" + }, + "unit": { + "const": "basis_points" + }, + "value": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + } + } + }, + "instrumentShortingThreshold": { + "type": "object", + "additionalProperties": false, + "required": [ + "instrument_id", + "value" + ], + "properties": { + "instrument_id": { + "$ref": "#/$defs/identifier" + }, + "value": { + "const": false + } + } + }, + "groupMoneyThreshold": { + "type": "object", + "additionalProperties": false, + "required": [ + "group_id", + "unit", + "value" + ], + "properties": { + "group_id": { + "$ref": "#/$defs/identifier" + }, + "unit": { + "const": "money" + }, + "value": { + "$ref": "#/$defs/positiveDecimal" + } + } + }, + "groupRatioThreshold": { + "type": "object", + "additionalProperties": false, + "required": [ + "group_id", + "unit", + "value" + ], + "properties": { + "group_id": { + "$ref": "#/$defs/identifier" + }, + "unit": { + "const": "ratio" + }, + "value": { + "$ref": "#/$defs/positiveDecimal" + } + } + }, + "fillClipReason": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "policy", + "threshold" + ], + "properties": { + "version": { + "const": "1" + }, + "policy": { + "const": "max_order_quantity" + }, + "threshold": { + "$ref": "#/$defs/quantityThreshold" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "policy", + "threshold" + ], + "properties": { + "version": { + "const": "1" + }, + "policy": { + "const": "max_long_position" + }, + "threshold": { + "$ref": "#/$defs/quantityThreshold" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "policy", + "threshold" + ], + "properties": { + "version": { + "const": "1" + }, + "policy": { + "const": "max_short_position" + }, + "threshold": { + "$ref": "#/$defs/quantityThreshold" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "policy", + "threshold" + ], + "properties": { + "version": { + "const": "1" + }, + "policy": { + "const": "max_gross_exposure" + }, + "threshold": { + "$ref": "#/$defs/moneyThreshold" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "policy", + "threshold" + ], + "properties": { + "version": { + "const": "1" + }, + "policy": { + "const": "max_leverage" + }, + "threshold": { + "$ref": "#/$defs/ratioThreshold" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "policy", + "threshold" + ], + "properties": { + "version": { + "const": "1" + }, + "policy": { + "const": "initial_margin" + }, + "threshold": { + "$ref": "#/$defs/basisPointsThreshold" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "policy", + "threshold" + ], + "properties": { + "version": { + "const": "1" + }, + "policy": { + "const": "instrument_max_long_position" + }, + "threshold": { + "$ref": "#/$defs/instrumentQuantityThreshold" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["version", "policy", "threshold"], + "properties": { + "version": { "const": "1" }, + "policy": { "const": "settlement_cash_buying_power" }, + "threshold": { "$ref": "#/$defs/currencyMoneyThreshold" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["version", "policy", "threshold"], + "properties": { + "version": { "const": "1" }, + "policy": { "const": "settlement_position_availability" }, + "threshold": { "$ref": "#/$defs/settlementPositionThreshold" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "policy", + "threshold" + ], + "properties": { + "version": { + "const": "1" + }, + "policy": { + "const": "instrument_max_short_position" + }, + "threshold": { + "$ref": "#/$defs/instrumentQuantityThreshold" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "policy", + "threshold" + ], + "properties": { + "version": { + "const": "1" + }, + "policy": { + "const": "instrument_max_notional_exposure" + }, + "threshold": { + "$ref": "#/$defs/instrumentMoneyThreshold" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "policy", + "threshold" + ], + "properties": { + "version": { + "const": "1" + }, + "policy": { + "const": "instrument_shorting_disabled" + }, + "threshold": { + "$ref": "#/$defs/instrumentShortingThreshold" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "policy", + "threshold" + ], + "properties": { + "version": { + "const": "1" + }, + "policy": { + "const": "instrument_borrow_availability" + }, + "threshold": { + "$ref": "#/$defs/instrumentQuantityThreshold" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "policy", + "threshold" + ], + "properties": { + "version": { + "const": "1" + }, + "policy": { + "const": "instrument_initial_margin" + }, + "threshold": { + "$ref": "#/$defs/instrumentBasisPointsThreshold" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "policy", + "threshold" + ], + "properties": { + "version": { + "const": "1" + }, + "policy": { + "const": "group_max_gross_exposure" + }, + "threshold": { + "$ref": "#/$defs/groupMoneyThreshold" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "policy", + "threshold" + ], + "properties": { + "version": { + "const": "1" + }, + "policy": { + "const": "group_max_long_exposure" + }, + "threshold": { + "$ref": "#/$defs/groupMoneyThreshold" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "policy", + "threshold" + ], + "properties": { + "version": { + "const": "1" + }, + "policy": { + "const": "group_max_short_exposure" + }, + "threshold": { + "$ref": "#/$defs/groupMoneyThreshold" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "policy", + "threshold" + ], + "properties": { + "version": { + "const": "1" + }, + "policy": { + "const": "group_max_absolute_net_exposure" + }, + "threshold": { + "$ref": "#/$defs/groupMoneyThreshold" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "policy", + "threshold" + ], + "properties": { + "version": { + "const": "1" + }, + "policy": { + "const": "group_max_concentration" + }, + "threshold": { + "$ref": "#/$defs/groupRatioThreshold" + } + } + } + ] + }, + "fillClipped": { + "type": "object", + "additionalProperties": false, + "required": [ + "reason", + "order_id", + "instrument_id", + "proposed_quantity", + "permitted_quantity", + "price" + ], + "properties": { + "reason": { + "$ref": "#/$defs/fillClipReason" + }, + "order_id": { + "$ref": "#/$defs/identifier" + }, + "instrument_id": { + "$ref": "#/$defs/identifier" + }, + "proposed_quantity": { + "$ref": "#/$defs/positiveDecimal" + }, + "permitted_quantity": { + "$ref": "#/$defs/unsignedDecimal" + }, + "price": { + "$ref": "#/$defs/positiveDecimal" + } + } + }, + "borrowFee": { + "type": "object", + "additionalProperties": false, + "required": [ + "instrument_id", + "quote_currency", + "short_quantity", + "reference_price", + "borrow_bps", + "period_start", + "period_end", + "fee" + ], + "properties": { + "instrument_id": { + "$ref": "#/$defs/identifier" + }, + "quote_currency": { + "$ref": "#/$defs/identifier" + }, + "short_quantity": { + "$ref": "#/$defs/positiveDecimal" + }, + "reference_price": { + "$ref": "#/$defs/positiveDecimal" + }, + "borrow_bps": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + }, + "period_start": { + "$ref": "#/$defs/timestamp" + }, + "period_end": { + "$ref": "#/$defs/timestamp" + }, + "fee": { + "$ref": "#/$defs/positiveDecimal" + } + } + }, + "borrowCharge": { + "type": "object", + "additionalProperties": false, + "required": [ + "observation", + "quote_currency", + "short_quantity", + "reference_price", + "day_count", + "compounding", + "period_start", + "period_end", + "amount" + ], + "properties": { + "observation": { + "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/borrowObservation" + }, + "quote_currency": { + "$ref": "#/$defs/identifier" + }, + "short_quantity": { + "$ref": "#/$defs/positiveDecimal" + }, + "reference_price": { + "$ref": "#/$defs/positiveDecimal" + }, + "day_count": { + "enum": [ + "actual_365", + "actual_360" + ] + }, + "compounding": { + "enum": [ + "simple", + "daily" + ] + }, + "period_start": { + "$ref": "#/$defs/timestamp" + }, + "period_end": { + "$ref": "#/$defs/timestamp" + }, + "amount": { + "$ref": "#/$defs/signedDecimal" + } + } + }, + "borrowRecall": { + "type": "object", + "additionalProperties": false, + "required": [ + "observation", + "short_quantity", + "close_out_quantity" + ], + "properties": { + "observation": { + "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/borrowObservation" + }, + "short_quantity": { + "$ref": "#/$defs/positiveDecimal" + }, + "close_out_quantity": { + "$ref": "#/$defs/unsignedDecimal" + } + } + }, + "cashInterest": { + "type": "object", + "additionalProperties": false, + "required": [ + "observation", + "opening_balance", + "applied_rate_bps", + "day_count", + "compounding", + "period_start", + "period_end", + "amount", + "closing_balance" + ], + "properties": { + "observation": { + "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/cashRateObservation" + }, + "opening_balance": { + "$ref": "#/$defs/signedDecimal" + }, + "applied_rate_bps": { + "type": "integer", + "minimum": -1000000, + "maximum": 1000000 + }, + "day_count": { + "enum": [ + "actual_365", + "actual_360" + ] + }, + "compounding": { + "enum": [ + "simple", + "daily" + ] + }, + "period_start": { + "$ref": "#/$defs/timestamp" + }, + "period_end": { + "$ref": "#/$defs/timestamp" + }, + "amount": { + "$ref": "#/$defs/signedDecimal" + }, + "closing_balance": { + "$ref": "#/$defs/signedDecimal" + } + } + }, + "cashAttribution": { + "type": "object", + "additionalProperties": false, + "required": [ + "currency", + "amount", + "fx_rate", + "base_value", + "interest", + "base_interest", + "settled_amount", + "unsettled_amount", + "base_settled_value", + "base_unsettled_value" + ], + "properties": { + "currency": { + "$ref": "#/$defs/identifier" + }, + "amount": { + "$ref": "#/$defs/signedDecimal" + }, + "fx_rate": { + "$ref": "#/$defs/positiveDecimal" + }, + "base_value": { + "$ref": "#/$defs/signedDecimal" + }, + "interest": { + "$ref": "#/$defs/signedDecimal" + }, + "base_interest": { + "$ref": "#/$defs/signedDecimal" + }, + "settled_amount": { + "$ref": "#/$defs/signedDecimal" + }, + "unsettled_amount": { + "$ref": "#/$defs/signedDecimal" + }, + "base_settled_value": { + "$ref": "#/$defs/signedDecimal" + }, + "base_unsettled_value": { + "$ref": "#/$defs/signedDecimal" + } + } + }, + "positionAttribution": { + "type": "object", + "additionalProperties": false, + "required": [ + "instrument_id", + "quote_currency", + "quantity", + "settled_quantity", + "unsettled_quantity", + "mark", + "fx_rate", + "market_value", + "base_market_value", + "cost_basis", + "base_cost_basis", + "realized_pnl", + "base_realized_pnl", + "unrealized_pnl", + "base_unrealized_pnl", + "dividend_pnl", + "base_dividend_pnl", + "execution_fees", + "base_execution_fees", + "borrow_fees", + "base_borrow_fees", + "total_fees", + "base_total_fees", + "execution_fee_components" + ], + "properties": { + "instrument_id": { + "$ref": "#/$defs/identifier" + }, + "quote_currency": { + "$ref": "#/$defs/identifier" + }, + "quantity": { + "$ref": "#/$defs/signedDecimal" + }, + "settled_quantity": { + "$ref": "#/$defs/signedDecimal" + }, + "unsettled_quantity": { + "$ref": "#/$defs/signedDecimal" + }, + "mark": { + "$ref": "#/$defs/positiveDecimal" + }, + "fx_rate": { + "$ref": "#/$defs/positiveDecimal" + }, + "market_value": { + "$ref": "#/$defs/signedDecimal" + }, + "base_market_value": { + "$ref": "#/$defs/signedDecimal" + }, + "cost_basis": { + "$ref": "#/$defs/signedDecimal" + }, + "base_cost_basis": { + "$ref": "#/$defs/signedDecimal" + }, + "realized_pnl": { + "$ref": "#/$defs/signedDecimal" + }, + "base_realized_pnl": { + "$ref": "#/$defs/signedDecimal" + }, + "unrealized_pnl": { + "$ref": "#/$defs/signedDecimal" + }, + "base_unrealized_pnl": { + "$ref": "#/$defs/signedDecimal" + }, + "dividend_pnl": { + "$ref": "#/$defs/signedDecimal" + }, + "base_dividend_pnl": { + "$ref": "#/$defs/signedDecimal" + }, + "execution_fees": { + "$ref": "#/$defs/signedDecimal" + }, + "base_execution_fees": { + "$ref": "#/$defs/signedDecimal" + }, + "borrow_fees": { + "$ref": "#/$defs/signedDecimal" + }, + "base_borrow_fees": { + "$ref": "#/$defs/signedDecimal" + }, + "total_fees": { + "$ref": "#/$defs/signedDecimal" + }, + "base_total_fees": { + "$ref": "#/$defs/signedDecimal" + }, + "execution_fee_components": { + "type": "array", + "items": { + "$ref": "#/$defs/feeComponentAttribution" + } + } + } + }, + "margin": { + "type": "object", + "additionalProperties": false, + "required": [ + "initial_requirement", + "maintenance_requirement", + "initial_excess", + "maintenance_excess", + "margin_call" + ], + "properties": { + "initial_requirement": { + "$ref": "#/$defs/unsignedDecimal" + }, + "maintenance_requirement": { + "$ref": "#/$defs/unsignedDecimal" + }, + "initial_excess": { + "$ref": "#/$defs/signedDecimal" + }, + "maintenance_excess": { + "$ref": "#/$defs/signedDecimal" + }, + "margin_call": { + "type": "boolean" + } + } + }, + "groupExposure": { + "type": "object", + "additionalProperties": false, + "required": [ + "group_id", + "gross_exposure", + "net_exposure", + "long_exposure", + "short_exposure", + "concentration" + ], + "properties": { + "group_id": { + "$ref": "#/$defs/identifier" + }, + "gross_exposure": { + "$ref": "#/$defs/unsignedDecimal" + }, + "net_exposure": { + "$ref": "#/$defs/signedDecimal" + }, + "long_exposure": { + "$ref": "#/$defs/unsignedDecimal" + }, + "short_exposure": { + "$ref": "#/$defs/unsignedDecimal" + }, + "concentration": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/signedDecimal" + } + ] + } + } + }, + "valuation": { + "type": "object", + "additionalProperties": false, + "required": [ + "base_currency", + "cash", + "settled_cash", + "unsettled_cash", + "net_market_value", + "long_market_value", + "short_market_value", + "gross_exposure", + "cost_basis", + "realized_pnl", + "unrealized_pnl", + "equity", + "dividend_pnl", + "execution_fees", + "borrow_fees", + "cash_interest", + "total_fees", + "cash_balances", + "positions", + "margin", + "group_exposures", + "execution_fee_components" + ], + "properties": { + "base_currency": { + "$ref": "#/$defs/identifier" + }, + "cash": { + "$ref": "#/$defs/signedDecimal" + }, + "settled_cash": { + "$ref": "#/$defs/signedDecimal" + }, + "unsettled_cash": { + "$ref": "#/$defs/signedDecimal" + }, + "net_market_value": { + "$ref": "#/$defs/signedDecimal" + }, + "long_market_value": { + "$ref": "#/$defs/unsignedDecimal" + }, + "short_market_value": { + "$ref": "#/$defs/unsignedDecimal" + }, + "gross_exposure": { + "$ref": "#/$defs/unsignedDecimal" + }, + "cost_basis": { + "$ref": "#/$defs/signedDecimal" + }, + "realized_pnl": { + "$ref": "#/$defs/signedDecimal" + }, + "unrealized_pnl": { + "$ref": "#/$defs/signedDecimal" + }, + "equity": { + "$ref": "#/$defs/signedDecimal" + }, + "dividend_pnl": { + "$ref": "#/$defs/signedDecimal" + }, + "execution_fees": { + "$ref": "#/$defs/signedDecimal" + }, + "borrow_fees": { + "$ref": "#/$defs/signedDecimal" + }, + "total_fees": { + "$ref": "#/$defs/signedDecimal" + }, + "cash_balances": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/cashAttribution" + } + }, + "positions": { + "type": "array", + "items": { + "$ref": "#/$defs/positionAttribution" + } + }, + "margin": { + "$ref": "#/$defs/margin" + }, + "group_exposures": { + "type": "array", + "items": { + "$ref": "#/$defs/groupExposure" + } + }, + "execution_fee_components": { + "type": "array", + "items": { + "$ref": "#/$defs/feeComponentAttribution" + } + }, + "cash_interest": { + "$ref": "#/$defs/signedDecimal" + } + } + }, + "intentRejected": { + "type": "object", + "additionalProperties": false, + "required": [ + "reason" + ], + "properties": { + "reason": { + "type": "string", + "minLength": 1 + } + } + }, + "metric": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "value" + ], + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "runCompleted": { + "type": "object", + "additionalProperties": false, + "required": [ + "scenario_sha256", + "execution_model", + "valuation", + "order_counts" + ], + "properties": { + "scenario_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "execution_model": { + "const": "completed_bar_v1" + }, + "valuation": { + "$ref": "#/$defs/valuation" + }, + "order_counts": { + "type": "object", + "additionalProperties": false, + "required": [ + "total", + "active", + "filled", + "rejected", + "cancelled" + ], + "properties": { + "total": { + "type": "integer", + "minimum": 0 + }, + "active": { + "type": "integer", + "minimum": 0 + }, + "filled": { + "type": "integer", + "minimum": 0 + }, + "rejected": { + "type": "integer", + "minimum": 0 + }, + "cancelled": { + "type": "integer", + "minimum": 0 + } + } + } + } + } + } +} diff --git a/contracts/v12/scenario-stream.schema.json b/contracts/v12/scenario-stream.schema.json new file mode 100644 index 0000000..e6aa4dd --- /dev/null +++ b/contracts/v12/scenario-stream.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/fallblu/trading-engine/contracts/v12/scenario-stream.schema.json", + "title": "Trading Engine v12 replay scenario stream record", + "description": "Market-slice records are ordered and non-overlapping: each start_at is at or after the prior end_at. Equal boundaries are valid. This cross-record rule is enforced semantically.", + "oneOf": [ + { "$ref": "#/$defs/headerRecord" }, + { "$ref": "#/$defs/sliceRecord" }, + { "$ref": "#/$defs/endRecord" } + ], + "$defs": { + "headerRecord": { + "type": "object", + "additionalProperties": false, + "required": ["contract_version", "scenario_sequence", "record_type", "payload"], + "properties": { + "contract_version": { "const": "12" }, + "scenario_sequence": { "const": "1" }, + "record_type": { "const": "scenario_header" }, + "payload": { "$ref": "#/$defs/headerPayload" } + } + }, + "sliceRecord": { + "type": "object", + "additionalProperties": false, + "required": ["contract_version", "scenario_sequence", "record_type", "payload"], + "properties": { + "contract_version": { "const": "12" }, + "scenario_sequence": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/sequence" }, + "record_type": { "const": "market_slice" }, + "payload": { "$ref": "#/$defs/slicePayload" } + } + }, + "endRecord": { + "type": "object", + "additionalProperties": false, + "required": ["contract_version", "scenario_sequence", "record_type", "payload"], + "properties": { + "contract_version": { "const": "12" }, + "scenario_sequence": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/sequence" }, + "record_type": { "const": "scenario_end" }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["slice_count"], + "properties": { "slice_count": { "type": "string", "pattern": "^(?:0|[1-9][0-9]*)$" } } + } + } + }, + "headerPayload": { + "type": "object", + "additionalProperties": false, + "required": ["metadata", "run_id", "base_currency", "initial_portfolio", "instruments", "venue_calendars", "risk", "execution", "financing", "settlement", "max_internal_events"], + "properties": { + "metadata": { "type": "object" }, + "run_id": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/identifier" }, + "base_currency": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/identifier" }, + "initial_portfolio": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/initialPortfolio" }, + "instruments": { "type": "array", "minItems": 1, "maxItems": 4096, "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/instrument" } }, + "venue_calendars": { "type": "array", "minItems": 1, "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/venueCalendar" } }, + "risk": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/risk" }, + "execution": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/execution" }, + "financing": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/financing" }, + "settlement": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/settlement" }, + "max_internal_events": { "type": "integer", "minimum": 1, "maximum": 100000 } + } + }, + "slicePayload": { + "type": "object", + "additionalProperties": false, + "required": ["market_slice", "intents"], + "properties": { + "market_slice": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/marketSlice" }, + "intents": { "type": "array", "maxItems": 4096, "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json#/$defs/intent" } } + } + } + } +} diff --git a/contracts/v12/scenario.schema.json b/contracts/v12/scenario.schema.json new file mode 100644 index 0000000..82c0c8c --- /dev/null +++ b/contracts/v12/scenario.schema.json @@ -0,0 +1,669 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/fallblu/trading-engine/contracts/v12/scenario.schema.json", + "title": "Trading Engine v12 replay scenario", + "description": "Strict deterministic scenario contract with explicit venue-local session policies resolved to absolute instants outside the reducer.", + "type": "object", + "additionalProperties": false, + "required": ["contract_version", "metadata", "run_id", "base_currency", "initial_portfolio", "instruments", "venue_calendars", "risk", "execution", "financing", "settlement", "max_internal_events", "schedule", "slices"], + "properties": { + "contract_version": { "const": "12" }, + "metadata": { "type": "object" }, + "run_id": { "$ref": "#/$defs/identifier" }, + "base_currency": { "$ref": "#/$defs/identifier" }, + "initial_portfolio": { "$ref": "#/$defs/initialPortfolio" }, + "instruments": { + "type": "array", + "minItems": 1, + "maxItems": 4096, + "items": { "$ref": "#/$defs/instrument" } + }, + "venue_calendars": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/venueCalendar" } + }, + "risk": { "$ref": "#/$defs/risk" }, + "execution": { "$ref": "#/$defs/execution" }, + "financing": { "$ref": "#/$defs/financing" }, + "settlement": { "$ref": "#/$defs/settlement" }, + "max_internal_events": { "type": "integer", "minimum": 1, "maximum": 100000 }, + "schedule": { "type": "array", "items": { "$ref": "#/$defs/scheduleItem" } }, + "slices": { + "description": "Slices are ordered and non-overlapping: each start_at is at or after the prior end_at. Equal boundaries are valid. This cross-item rule is enforced semantically.", + "type": "array", + "items": { "$ref": "#/$defs/marketSlice" } + } + }, + "$defs": { + "settlement": { + "type": "object", + "additionalProperties": false, + "required": ["cash_buying_power", "position_availability", "calendars", "rules"], + "properties": { + "cash_buying_power": { "enum": ["total_cash", "settled_cash"] }, + "position_availability": { "enum": ["total_positions", "settled_positions"] }, + "calendars": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/settlementCalendar" } }, + "rules": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/settlementRule" } } + } + }, + "settlementCalendar": { + "type": "object", + "additionalProperties": false, + "required": ["calendar_id", "version", "business_dates"], + "properties": { + "calendar_id": { "$ref": "#/$defs/identifier" }, + "version": { "const": "1" }, + "business_dates": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "format": "date", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } } + } + }, + "settlementRule": { + "type": "object", + "additionalProperties": false, + "required": ["instrument_id", "calendar_id", "lag_business_days"], + "properties": { + "instrument_id": { "$ref": "#/$defs/identifier" }, + "calendar_id": { "$ref": "#/$defs/identifier" }, + "lag_business_days": { "type": "integer", "minimum": 0, "maximum": 30 } + } + }, + "settlementFailure": { + "type": "object", + "additionalProperties": false, + "required": ["instruction_id", "reason"], + "properties": { + "instruction_id": { "$ref": "#/$defs/identifier" }, + "reason": { "type": "string", "minLength": 1, "pattern": "^\\S(?:.*\\S)?$" } + } + }, + "financing": { + "type": "object", + "additionalProperties": false, + "required": ["day_count", "compounding", "borrow_missing_data", "cash_missing_data", "locate_policy", "recall_policy"], + "properties": { + "day_count": { "enum": ["actual_365", "actual_360"] }, + "compounding": { "enum": ["simple", "daily"] }, + "borrow_missing_data": { "enum": ["reject", "zero"] }, + "cash_missing_data": { "enum": ["reject", "zero"] }, + "locate_policy": { "enum": ["reject_order", "clip_fill"] }, + "recall_policy": { "enum": ["reject_new_shorts", "close_out"] } + } + }, + "borrowObservation": { + "type": "object", + "additionalProperties": false, + "required": ["instrument_id", "effective_at", "available_quantity", "annual_rate_bps", "recalled"], + "properties": { + "instrument_id": { "$ref": "#/$defs/identifier" }, + "effective_at": { "$ref": "#/$defs/timestamp" }, + "available_quantity": { "$ref": "#/$defs/unsignedDecimal" }, + "annual_rate_bps": { "type": "integer", "minimum": -1000000, "maximum": 1000000 }, + "recalled": { "type": "boolean" } + } + }, + "cashRateObservation": { + "type": "object", + "additionalProperties": false, + "required": ["currency", "effective_at", "credit_rate_bps", "debit_rate_bps"], + "properties": { + "currency": { "$ref": "#/$defs/identifier" }, + "effective_at": { "$ref": "#/$defs/timestamp" }, + "credit_rate_bps": { "type": "integer", "minimum": -1000000, "maximum": 1000000 }, + "debit_rate_bps": { "type": "integer", "minimum": -1000000, "maximum": 1000000 } + } + }, + "identifier": { + "type": "string", + "minLength": 1, + "pattern": "^[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "signedDecimal": { + "type": "string", + "pattern": "^(?:0|-?(?:0[.][0-9]{0,5}[1-9]|[1-9][0-9]*(?:[.][0-9]{0,5}[1-9])?))$" + }, + "unsignedDecimal": { + "type": "string", + "pattern": "^(?:0|0[.][0-9]{0,5}[1-9]|[1-9][0-9]*(?:[.][0-9]{0,5}[1-9])?)$" + }, + "positiveDecimal": { + "type": "string", + "pattern": "^(?:0[.][0-9]{0,5}[1-9]|[1-9][0-9]*(?:[.][0-9]{0,5}[1-9])?)$" + }, + "sequence": { "type": "string", "pattern": "^[1-9][0-9]*$" }, + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:[0-9]{2}:[0-5][0-9](?:[.][0-9]{1,6})?(?:[zZ]|[+-][0-9]{2}:[0-9]{2})$" + }, + "cashBalance": { + "type": "object", + "additionalProperties": false, + "required": ["currency", "amount"], + "properties": { + "currency": { "$ref": "#/$defs/identifier" }, + "amount": { "$ref": "#/$defs/signedDecimal" } + } + }, + "initialPortfolio": { + "type": "object", + "additionalProperties": false, + "required": ["cash", "positions", "marks", "fx_rates"], + "properties": { + "cash": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/cashBalance" } }, + "positions": { "type": "array", "items": { "$ref": "#/$defs/initialPosition" } }, + "marks": { "type": "array", "items": { "$ref": "#/$defs/initialMark" } }, + "fx_rates": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/fxRate" } } + } + }, + "initialPosition": { + "type": "object", + "additionalProperties": false, + "required": ["instrument_id", "quantity", "cost_basis", "realized_pnl", "dividend_pnl", "execution_fees", "borrow_fees"], + "properties": { + "instrument_id": { "$ref": "#/$defs/identifier" }, + "quantity": { "$ref": "#/$defs/signedDecimal" }, + "cost_basis": { "$ref": "#/$defs/signedDecimal" }, + "realized_pnl": { "$ref": "#/$defs/signedDecimal" }, + "dividend_pnl": { "$ref": "#/$defs/signedDecimal" }, + "execution_fees": { "$ref": "#/$defs/unsignedDecimal" }, + "borrow_fees": { "$ref": "#/$defs/unsignedDecimal" } + } + }, + "initialMark": { + "type": "object", + "additionalProperties": false, + "required": ["instrument_id", "price"], + "properties": { + "instrument_id": { "$ref": "#/$defs/identifier" }, + "price": { "$ref": "#/$defs/positiveDecimal" } + } + }, + "instrument": { + "type": "object", + "additionalProperties": false, + "required": ["instrument_id", "symbol", "quote_currency", "tick_size", "lot_size"], + "properties": { + "instrument_id": { "$ref": "#/$defs/identifier" }, + "symbol": { "$ref": "#/$defs/identifier" }, + "quote_currency": { "$ref": "#/$defs/identifier" }, + "tick_size": { "$ref": "#/$defs/positiveDecimal" }, + "lot_size": { "$ref": "#/$defs/positiveDecimal" } + } + }, + "venueCalendar": { + "type": "object", + "additionalProperties": false, + "required": ["calendar_id", "calendar_version", "venue_id", "instrument_ids", "sessions"], + "properties": { + "calendar_id": { "$ref": "#/$defs/identifier" }, + "calendar_version": { "const": "1" }, + "venue_id": { "$ref": "#/$defs/identifier" }, + "instrument_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "$ref": "#/$defs/identifier" } + }, + "sessions": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/venueSession" } + } + } + }, + "venueSession": { + "oneOf": [ + { "$ref": "#/$defs/openVenueSession" }, + { "$ref": "#/$defs/holidayVenueSession" } + ] + }, + "openVenueSession": { + "type": "object", + "additionalProperties": false, + "required": ["session_date", "policy", "phases"], + "properties": { + "session_date": { "type": "string", "format": "date", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }, + "policy": { "enum": ["regular", "early_close"] }, + "phases": { + "type": "array", + "minItems": 1, + "maxItems": 5, + "items": { "$ref": "#/$defs/venuePhase" } + } + } + }, + "holidayVenueSession": { + "type": "object", + "additionalProperties": false, + "required": ["session_date", "policy", "phases"], + "properties": { + "session_date": { "type": "string", "format": "date", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }, + "policy": { "const": "holiday" }, + "phases": { "type": "array", "maxItems": 0 } + } + }, + "venuePhase": { + "type": "object", + "additionalProperties": false, + "required": ["phase", "opens_at", "closes_at"], + "properties": { + "phase": { "enum": ["premarket", "opening_auction", "regular", "closing_auction", "postmarket"] }, + "opens_at": { "$ref": "#/$defs/timestamp" }, + "closes_at": { "$ref": "#/$defs/timestamp" } + } + }, + "risk": { + "type": "object", + "additionalProperties": false, + "required": ["max_gross_exposure", "max_leverage", "short_borrow_bps", "instrument_policies", "groups"], + "properties": { + "max_gross_exposure": { "$ref": "#/$defs/positiveDecimal" }, + "max_leverage": { "$ref": "#/$defs/positiveDecimal" }, + "short_borrow_bps": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "instrument_policies": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/instrumentRiskPolicy" } + }, + "groups": { + "type": "array", + "items": { "$ref": "#/$defs/riskGroup" } + } + } + }, + "instrumentRiskPolicy": { + "type": "object", + "additionalProperties": false, + "required": ["instrument_id", "max_order_quantity", "max_long_position", "max_short_position", "max_notional_exposure", "initial_margin_bps", "maintenance_margin_bps", "shorting_allowed"], + "properties": { + "instrument_id": { "$ref": "#/$defs/identifier" }, + "max_order_quantity": { "$ref": "#/$defs/positiveDecimal" }, + "max_long_position": { "$ref": "#/$defs/positiveDecimal" }, + "max_short_position": { "$ref": "#/$defs/positiveDecimal" }, + "max_notional_exposure": { "$ref": "#/$defs/positiveDecimal" }, + "initial_margin_bps": { "type": "integer", "minimum": 1, "maximum": 10000 }, + "maintenance_margin_bps": { "type": "integer", "minimum": 1, "maximum": 10000 }, + "shorting_allowed": { "type": "boolean" } + } + }, + "nullablePositiveDecimal": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/positiveDecimal" } + ] + }, + "riskGroup": { + "type": "object", + "additionalProperties": false, + "required": ["group_id", "group_version", "group_type", "instrument_ids", "limits"], + "properties": { + "group_id": { "$ref": "#/$defs/identifier" }, + "group_version": { "const": "1" }, + "group_type": { "enum": ["issuer", "sector", "currency", "country", "asset_class", "custom"] }, + "instrument_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "$ref": "#/$defs/identifier" } + }, + "limits": { "$ref": "#/$defs/riskGroupLimits" } + } + }, + "riskGroupLimits": { + "type": "object", + "additionalProperties": false, + "required": ["max_gross_exposure", "max_long_exposure", "max_short_exposure", "max_absolute_net_exposure", "max_concentration"], + "properties": { + "max_gross_exposure": { "$ref": "#/$defs/nullablePositiveDecimal" }, + "max_long_exposure": { "$ref": "#/$defs/nullablePositiveDecimal" }, + "max_short_exposure": { "$ref": "#/$defs/nullablePositiveDecimal" }, + "max_absolute_net_exposure": { "$ref": "#/$defs/nullablePositiveDecimal" }, + "max_concentration": { + "oneOf": [ + { "type": "null" }, + { "allOf": [{ "$ref": "#/$defs/positiveDecimal" }, { "pattern": "^(?:0[.][0-9]{0,5}[1-9]|1)$" }] } + ] + } + } + }, + "execution": { + "type": "object", + "additionalProperties": false, + "required": ["model", "configuration"], + "properties": { + "model": { "const": "completed_bar_v1" }, + "configuration": { "$ref": "#/$defs/completedBarV1Configuration" } + } + }, + "completedBarV1Configuration": { + "type": "object", + "additionalProperties": false, + "required": ["version", "participation_bps", "fee_schedules"], + "properties": { + "version": { "const": "2" }, + "participation_bps": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "fee_schedules": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/feeSchedule" } } + } + }, + "feeSchedule": { + "type": "object", + "additionalProperties": false, + "required": ["schedule_id", "instrument_id", "settlement_currency", "minimum", "maximum", "components"], + "properties": { + "schedule_id": { "$ref": "#/$defs/identifier" }, + "instrument_id": { "$ref": "#/$defs/identifier" }, + "settlement_currency": { "$ref": "#/$defs/identifier" }, + "minimum": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/unsignedDecimal" }] }, + "maximum": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/unsignedDecimal" }] }, + "components": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/feeComponent" } } + } + }, + "feeComponent": { + "type": "object", + "additionalProperties": false, + "required": ["name", "currency", "kind", "value", "rounding", "applies_to"], + "properties": { + "name": { "$ref": "#/$defs/identifier" }, + "currency": { "$ref": "#/$defs/identifier" }, + "kind": { "enum": ["fixed", "notional_bps", "per_unit"] }, + "value": { "oneOf": [{ "$ref": "#/$defs/signedDecimal" }, { "type": "integer", "minimum": -10000, "maximum": 10000 }] }, + "rounding": { "enum": ["up", "down", "nearest"] }, + "applies_to": { "enum": ["any", "maker", "taker"] } + }, + "allOf": [ + { "if": { "properties": { "kind": { "const": "notional_bps" } } }, "then": { "properties": { "value": { "type": "integer" } } } }, + { "if": { "properties": { "kind": { "enum": ["fixed", "per_unit"] } } }, "then": { "properties": { "value": { "$ref": "#/$defs/signedDecimal" } } } } + ] + }, + "scheduleItem": { + "type": "object", + "additionalProperties": false, + "required": ["after_slice_sequence", "intents"], + "properties": { + "after_slice_sequence": { "$ref": "#/$defs/sequence" }, + "intents": { "type": "array", "maxItems": 4096, "items": { "$ref": "#/$defs/intent" } } + } + }, + "intent": { + "oneOf": [ + { "$ref": "#/$defs/targetWeights" }, + { "$ref": "#/$defs/targetQuantities" }, + { "$ref": "#/$defs/submitOrder" }, + { "$ref": "#/$defs/cancelOrder" }, + { "$ref": "#/$defs/metric" } + ] + }, + "targetWeights": { + "type": "object", + "additionalProperties": false, + "required": ["type", "targets"], + "properties": { + "type": { "const": "target_weights" }, + "targets": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["instrument_id", "weight"], + "properties": { + "instrument_id": { "$ref": "#/$defs/identifier" }, + "weight": { "$ref": "#/$defs/signedDecimal" } + } + } + } + } + }, + "targetQuantities": { + "type": "object", + "additionalProperties": false, + "required": ["type", "targets"], + "properties": { + "type": { "const": "target_quantities" }, + "targets": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["instrument_id", "quantity"], + "properties": { + "instrument_id": { "$ref": "#/$defs/identifier" }, + "quantity": { "$ref": "#/$defs/signedDecimal" } + } + } + } + } + }, + "submitOrder": { + "type": "object", + "additionalProperties": false, + "required": ["type", "instrument_id", "side", "quantity", "order_kind", "trigger_price", "limit_price", "time_in_force", "venue_id", "calendar_id", "expires_at"], + "properties": { + "type": { "const": "submit_order" }, + "instrument_id": { "$ref": "#/$defs/identifier" }, + "side": { "enum": ["buy", "sell"] }, + "quantity": { "$ref": "#/$defs/positiveDecimal" }, + "order_kind": { "enum": ["market", "limit", "stop", "stop_limit"] }, + "trigger_price": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/positiveDecimal" }] }, + "limit_price": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/positiveDecimal" }] }, + "time_in_force": { "enum": ["gtc", "ioc", "fok", "day", "gtd"] }, + "venue_id": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/identifier" }] }, + "calendar_id": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/identifier" }] }, + "expires_at": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/timestamp" }] } + }, + "allOf": [ + { "if": { "properties": { "order_kind": { "const": "market" } } }, "then": { "properties": { "trigger_price": { "type": "null" }, "limit_price": { "type": "null" } } } }, + { "if": { "properties": { "order_kind": { "const": "limit" } } }, "then": { "properties": { "trigger_price": { "type": "null" }, "limit_price": { "$ref": "#/$defs/positiveDecimal" } } } }, + { "if": { "properties": { "order_kind": { "const": "stop" } } }, "then": { "properties": { "trigger_price": { "$ref": "#/$defs/positiveDecimal" }, "limit_price": { "type": "null" } } } }, + { "if": { "properties": { "order_kind": { "const": "stop_limit" } } }, "then": { "properties": { "trigger_price": { "$ref": "#/$defs/positiveDecimal" }, "limit_price": { "$ref": "#/$defs/positiveDecimal" } } } }, + { "if": { "properties": { "time_in_force": { "const": "day" } } }, "then": { "properties": { "venue_id": { "$ref": "#/$defs/identifier" }, "calendar_id": { "$ref": "#/$defs/identifier" }, "expires_at": { "type": "null" } } } }, + { "if": { "properties": { "time_in_force": { "const": "gtd" } } }, "then": { "properties": { "venue_id": { "type": "null" }, "calendar_id": { "type": "null" }, "expires_at": { "$ref": "#/$defs/timestamp" } } } }, + { "if": { "properties": { "time_in_force": { "enum": ["gtc", "ioc", "fok"] } } }, "then": { "properties": { "venue_id": { "type": "null" }, "calendar_id": { "type": "null" }, "expires_at": { "type": "null" } } } } + ] + }, + "cancelOrder": { + "type": "object", + "additionalProperties": false, + "required": ["type", "order_id"], + "properties": { + "type": { "const": "cancel_order" }, + "order_id": { "$ref": "#/$defs/identifier" } + } + }, + "metric": { + "type": "object", + "additionalProperties": false, + "required": ["type", "name", "value"], + "properties": { + "type": { "const": "emit_metric" }, + "name": { "type": "string" }, + "value": { "type": "string" } + } + }, + "marketSlice": { + "type": "object", + "additionalProperties": false, + "required": ["slice_sequence", "start_at", "end_at", "available_at", "received_at", "bars", "fx_rates", "corporate_actions", "borrow_observations", "cash_rate_observations", "settlement_failures", "lifecycle_events"], + "properties": { + "slice_sequence": { "$ref": "#/$defs/sequence" }, + "start_at": { "$ref": "#/$defs/timestamp" }, + "end_at": { "$ref": "#/$defs/timestamp" }, + "available_at": { "$ref": "#/$defs/timestamp" }, + "received_at": { "$ref": "#/$defs/timestamp" }, + "bars": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/bar" } }, + "fx_rates": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/fxRate" } }, + "corporate_actions": { "type": "array", "items": { "$ref": "#/$defs/corporateAction" } }, + "borrow_observations": { "type": "array", "items": { "$ref": "#/$defs/borrowObservation" } }, + "cash_rate_observations": { "type": "array", "items": { "$ref": "#/$defs/cashRateObservation" } }, + "settlement_failures": { "type": "array", "items": { "$ref": "#/$defs/settlementFailure" } }, + "lifecycle_events": { "type": "array", "items": { "$ref": "#/$defs/lifecycleEvent" } } + } + }, + "bar": { + "type": "object", + "additionalProperties": false, + "required": ["instrument_id", "open", "high", "low", "close", "volume"], + "properties": { + "instrument_id": { "$ref": "#/$defs/identifier" }, + "open": { "$ref": "#/$defs/positiveDecimal" }, + "high": { "$ref": "#/$defs/positiveDecimal" }, + "low": { "$ref": "#/$defs/positiveDecimal" }, + "close": { "$ref": "#/$defs/positiveDecimal" }, + "volume": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/unsignedDecimal" }] } + } + }, + "fxRate": { + "type": "object", + "additionalProperties": false, + "required": ["currency", "rate"], + "properties": { + "currency": { "$ref": "#/$defs/identifier" }, + "rate": { "$ref": "#/$defs/positiveDecimal" } + } + }, + "corporateAction": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["type", "action_id", "instrument_id", "numerator", "denominator"], + "properties": { + "type": { "const": "split" }, + "action_id": { "$ref": "#/$defs/identifier" }, + "instrument_id": { "$ref": "#/$defs/identifier" }, + "numerator": { "$ref": "#/$defs/sequence" }, + "denominator": { "$ref": "#/$defs/sequence" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "action_id", "instrument_id", "amount_per_unit"], + "properties": { + "type": { "const": "cash_dividend" }, + "action_id": { "$ref": "#/$defs/identifier" }, + "instrument_id": { "$ref": "#/$defs/identifier" }, + "amount_per_unit": { "$ref": "#/$defs/positiveDecimal" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "action_id", "instrument_id", "destination_instrument_id", "numerator", "denominator", "basis_allocation_bps", "fractional_policy"], + "properties": { + "type": { "enum": ["stock_dividend", "rights", "spin_off"] }, + "action_id": { "$ref": "#/$defs/identifier" }, + "instrument_id": { "$ref": "#/$defs/identifier" }, + "destination_instrument_id": { "$ref": "#/$defs/identifier" }, + "numerator": { "$ref": "#/$defs/sequence" }, + "denominator": { "$ref": "#/$defs/sequence" }, + "basis_allocation_bps": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "fractional_policy": { "$ref": "#/$defs/fractionalPolicy" } + } + } + ] + }, + "fractionalPolicy": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["policy"], + "properties": { "policy": { "const": "reject" } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["policy", "price", "currency"], + "properties": { + "policy": { "const": "cash_in_lieu" }, + "price": { "$ref": "#/$defs/positiveDecimal" }, + "currency": { "$ref": "#/$defs/identifier" } + } + } + ] + }, + "terminalPolicy": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["policy"], + "properties": { "policy": { "const": "hold" } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["policy", "price", "currency"], + "properties": { + "policy": { "const": "cash_out" }, + "price": { "$ref": "#/$defs/positiveDecimal" }, + "currency": { "$ref": "#/$defs/identifier" } + } + } + ] + }, + "lifecycleEvent": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["type", "event_id", "instrument_id", "reason"], + "properties": { + "type": { "const": "halt" }, + "event_id": { "$ref": "#/$defs/identifier" }, + "instrument_id": { "$ref": "#/$defs/identifier" }, + "reason": { "type": "string", "minLength": 1 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "event_id", "instrument_id"], + "properties": { + "type": { "const": "resume" }, + "event_id": { "$ref": "#/$defs/identifier" }, + "instrument_id": { "$ref": "#/$defs/identifier" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "event_id", "instrument_id", "symbol", "provider", "provider_instrument_id"], + "properties": { + "type": { "const": "identifier_change" }, + "event_id": { "$ref": "#/$defs/identifier" }, + "instrument_id": { "$ref": "#/$defs/identifier" }, + "symbol": { "$ref": "#/$defs/identifier" }, + "provider": { "$ref": "#/$defs/identifier" }, + "provider_instrument_id": { "$ref": "#/$defs/identifier" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "event_id", "instrument_id", "terminal_policy"], + "properties": { + "type": { "const": "expiration" }, + "event_id": { "$ref": "#/$defs/identifier" }, + "instrument_id": { "$ref": "#/$defs/identifier" }, + "terminal_policy": { "$ref": "#/$defs/terminalPolicy" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "event_id", "instrument_id", "terminal_policy", "reason"], + "properties": { + "type": { "const": "delisting" }, + "event_id": { "$ref": "#/$defs/identifier" }, + "instrument_id": { "$ref": "#/$defs/identifier" }, + "terminal_policy": { "$ref": "#/$defs/terminalPolicy" }, + "reason": { "type": "string", "minLength": 1 } + } + } + ] + } + } +} diff --git a/docs/api-reference.md b/docs/api-reference.md index 3734265..b93d25d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -7,4 +7,4 @@ that every public interface has a corresponding page. The generated reference describes library types and functions. The versioned JSON and JSON Lines -files under [Contracts](../contracts/v11/README.md) remain authoritative for process boundaries. +files under [Contracts](../contracts/v12/README.md) remain authoritative for process boundaries. diff --git a/docs/architecture.md b/docs/architecture.md index b1b1bbf..8f6357f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,7 +55,8 @@ For each synchronized market slice, the engine: 1. Validates catalog coverage, slice order, receipt order, and market time. 2. Stores the synchronized closes and complete FX vector, emits `run_started` once, and then emits `market_slice_received`. -3. Applies splits and dividends, adjusting signed positions, persistent targets, and active orders. +3. Applies corporate distributions and lifecycle events, adjusting positions, basis, targets, + listings, and active orders. 4. Accrues borrow fees on open shorts for the slice interval. 5. Fixes the priority sequence of orders that became eligible after an earlier slice. 6. Offers each instrument's remaining capacity to liquidation orders first, then applies diff --git a/docs/continuous-integration.md b/docs/continuous-integration.md index c179de0..b635fb7 100644 --- a/docs/continuous-integration.md +++ b/docs/continuous-integration.md @@ -21,7 +21,7 @@ Every runtime cell replays the frozen v3 demo, v5 demo, and v5 risk-limited fill canonical fixtures. Standard output and standard error are captured separately because human diagnostics may contain platform-specific paths or process details and are not part of the journal contract. -The full test suite additionally validates and replays the current v11 batch, stream, journal, and +The full test suite additionally validates and replays the current v12 batch, stream, journal, and strategy-v8 fixtures, including financing attribution and the reconciled first valuation. Coverage runs once in the exact locked Ubuntu environment. The required Persistra job also runs diff --git a/docs/execution-model.md b/docs/execution-model.md index b07c337..e20c710 100644 --- a/docs/execution-model.md +++ b/docs/execution-model.md @@ -1,11 +1,11 @@ # Execution model The engine selects a compiled execution module by the scenario's stable `execution.model` name. -Contract v11 advertises and accepts `completed_bar_v1`; embedders can inject another module through +Contract v12 advertises and accepts `completed_bar_v1`; embedders can inject another module through the typed engine configuration without introducing runtime shared-library loading. The selected name is repeated in both terminal audit records. -Each compiled model owns a strict configuration contract. The v11 envelope separates selection from +Each compiled model owns a strict configuration contract. The v12 envelope separates selection from model-specific parameters: ```json @@ -143,6 +143,14 @@ order may exceed that maximum, but no individual fill may do so. A cash dividend multiplies the pre-match signed position by its per-unit amount. It credits a long or debits a short in the instrument's quote-currency ledger and records realized dividend P&L. +Stock dividends, rights, and spin-offs deliver a lot-aligned exact-ratio entitlement. Their payload +allocates basis explicitly and either rejects fractions or converts them at a declared +quote-currency price. Stock dividends also scale persistent targets and eligible working orders. + +Lifecycle events follow corporate actions and precede matching. Identifier changes preserve the +stable instrument ID while updating the symbol and named provider mapping. Halts and terminal +events cancel active orders. Expiration and delisting use an explicit hold or cash-out policy and +cannot be resumed. Contract v10 replaces the fixed legacy rate with effective-time borrow observations. Each observation names an instrument, available quantity, annual rate in basis points, and recall state. Observations become active no later than the slice start and remain active until superseded. A new diff --git a/docs/persistra.md b/docs/persistra.md index 4b66b78..285b155 100644 --- a/docs/persistra.md +++ b/docs/persistra.md @@ -54,12 +54,12 @@ lifecycle belong to Persistra. Persistra currently uses the transitional v3 [scenario](../contracts/v3/scenario.schema.json) and [journal](../contracts/v3/journal.schema.json) schemas and their adjacent conformance fixtures for -structural checks. The engine advertises current contract v11 while retaining v10 through v3 and +structural checks. The engine advertises current contract v12 while retaining v11 through v3 and exact v3 journal output for v3 inputs. The engine parser is authoritative for ordering, catalog coverage, causality, tick, lot, risk, and accounting invariants that JSON Schema cannot express. External strategies use the separate -[strategy protocol v9](../contracts/strategy/v9/README.md). Persistra's host turns protocol +[strategy protocol v10](../contracts/strategy/v10/README.md). Persistra's host turns protocol initialization, marked portfolio contexts, market-slice, fill, order, and rejection events into typed callbacks. Realized weights are available only for positive equity. The retained run manifest binds the strategy identity, executable hash, declared input hashes, transcript hash, @@ -79,7 +79,7 @@ compatibility claim. - **Engine:** `--capabilities` is the authoritative machine-readable surface. The engine must reject unsupported versions and malformed or semantically invalid input before reporting a successful run. -- **Scenario:** Frozen scenario and stream artifacts do not change. The current v11 contract may +- **Scenario:** Frozen scenario and stream artifacts do not change. The current v12 contract may receive additive changes only when old valid inputs retain their meaning; breaking changes need a new version. Transitional v3 support remains explicit in `--capabilities`. - **Journal:** A run emits the journal version paired with its accepted scenario. Record ordering, diff --git a/docs/scenario.md b/docs/scenario.md index 613f761..663ccf6 100644 --- a/docs/scenario.md +++ b/docs/scenario.md @@ -4,8 +4,8 @@ A replay scenario uses either one strict JSON object or a strict JSON Lines stre weights, quantities, money, and sequences are canonical JSON strings. Counts and basis points are JSON integers. Unknown, missing, duplicate, noncanonical, and non-finite values fail parsing. -Use [the v11 demo](../contracts/v11/fixtures/demo.scenario.json) as the canonical complete example. -The [scenario JSON Schema](../contracts/v11/scenario.schema.json) provides structural validation. +Use [the v12 demo](../contracts/v12/fixtures/demo.scenario.json) as the canonical complete example. +The [scenario JSON Schema](../contracts/v12/scenario.schema.json) provides structural validation. The engine parser also enforces cross-field and cross-record invariants. Diagnostics identify the failed field or array item. Stream diagnostics additionally retain the record line and sequence. @@ -32,8 +32,8 @@ The batch object and stream header share one domain-construction path and the sa checks. Stream items reuse the batch slice and intent validators directly; no synthetic batch scenario is constructed. -The [stream record JSON Schema](../contracts/v11/scenario-stream.schema.json) validates each line, -and [the v11 stream fixture](../contracts/v11/fixtures/demo.scenario.jsonl) is the canonical example. +The [stream record JSON Schema](../contracts/v12/scenario-stream.schema.json) validates each line, +and [the v12 stream fixture](../contracts/v12/fixtures/demo.scenario.jsonl) is the canonical example. The engine validates the entire stream before creating a journal. It then replays one record at a time without retaining prior slices, scheduled batches, or audit events. Reducer state still retains current account, order, target, and latest-bar state required by execution semantics. @@ -42,7 +42,7 @@ retains current account, order, target, and latest-bar state required by executi | Field | Meaning | |---|---| -| `contract_version` | Required string identifying this file contract; v11 is `"11"` | +| `contract_version` | Required string identifying this file contract; v12 is `"12"` | | `metadata` | Required arbitrary JSON object preserved for provenance and ignored by execution | | `run_id` | Stable identity used in generated IDs | | `base_currency` | Reporting currency used for aggregate risk and valuation | @@ -222,7 +222,7 @@ has zero available quantity. The latest observation remains active until replace missing-data handling, `reject_order` or `clip_fill` locate behavior, and `reject_new_shorts` or `close_out` recall behavior. -The v11 `settlement` object selects `total_cash` or `settled_cash` buying power and +The v12 `settlement` object selects `total_cash` or `settled_cash` buying power and `total_positions` or `settled_positions` availability. Its immutable calendars contain ordered canonical business dates, and each instrument has exactly one calendar and a lag from zero through 30 business days. A fill updates economic accounting immediately and creates a deterministic @@ -230,7 +230,11 @@ instruction. Pending cash and quantity appear as unsettled attribution until the after the due date. A due instruction named in that slice's `settlement_failures` becomes failed instead, retains its unsettled balances, and records the supplied reason. -Supported corporate actions are exact-ratio `split` and per-unit `cash_dividend` records. Action +Supported corporate actions are exact-ratio `split`, per-unit `cash_dividend`, `stock_dividend`, +`rights`, and `spin_off` records. Distribution records name a destination instrument, entitlement +ratio, basis allocation in basis points, and a fractional policy. `reject` fails on a non-lot +entitlement; `cash_in_lieu` requires an explicit destination-quote-currency price and journals the +delivered quantity, fractional quantity, allocated basis, fractional basis, and cash amount. Action IDs are unique across the scenario. Actions are applied in canonical ID order before borrow fees and matching. A split rescales the position, persistent target, and active orders while preserving basis; it does not rescale unit-based risk limits. Split-adjusted positions and targets are @@ -239,12 +243,19 @@ but each fill is bounded by `max_order_quantity`; GTC limit remainders may fill while market IOC remainders are cancelled. A dividend changes the quote-currency cash ledger and realized dividend P&L, crediting a long and debiting a short. +Version 12 slices also carry `lifecycle_events`. Stable `instrument_id` never changes. An +`identifier_change` updates the current symbol and one named provider mapping with provenance; +`halt` and `resume` control whether new exposure is accepted. `expiration` and `delisting` are +terminal and require either `hold` or an explicit quote-currency `cash_out` price. Halts and +terminal events cancel active orders. Terminal events set persistent target exposure to zero and +cash-out clears the position with exact realized-P&L attribution. + For causal next-open execution, an order-changing schedule entry's anchor `received_at` is no later than the next slice `start_at`. ## Audit journal -The [journal JSON Schema](../contracts/v11/journal.schema.json) validates each JSON Lines record. +The [journal JSON Schema](../contracts/v12/journal.schema.json) validates each JSON Lines record. Every record contains `contract_version`, `engine_sequence`, deterministic `event_id`, ordered `causation_ids`, `run_id`, `recorded_at`, `event_type`, and an event-specific `payload`. Causal references are unique prior event IDs from the same run. The version is repeated on every record @@ -263,7 +274,8 @@ fill and the greatest lot-aligned permitted quantity. Its reason taxonomy versio ratio, or basis-points threshold. Each order snapshot retains both creation and latest-update event IDs. -The journal also records split/dividend application, split-driven order adjustments, observed +The journal also records split/dividend/distribution application, lifecycle transitions, +action-driven order adjustments, observed borrow charges, recalls and close-outs, cash-interest entries, margin calls, liquidation-origin orders, and restoration. Every valuation contains complete per-currency cash attribution, signed per-instrument native and base-currency attribution, long, diff --git a/lib/account.ml b/lib/account.ml index 2d4b2af..ecfffbf 100644 --- a/lib/account.ml +++ b/lib/account.ml @@ -469,6 +469,175 @@ let apply_cash_dividend (state : t) ~instrument_id ~quote_currency positions = update_position state.positions instrument_id updated; } +type distribution_result = { + source_quantity : Scalar.Quantity.t; + destination_quantity : Scalar.Quantity.t; + fractional_quantity : Scalar.Quantity.t; + allocated_basis : Scalar.Money.t; + fractional_basis : Scalar.Money.t; + cash_in_lieu : Scalar.Money.t; +} + +let money_bps_toward_zero value bps = + let numerator = + Z.mul (Z.of_int64 (Scalar.Money.to_micros value)) (Z.of_int bps) + in + let result = Z.div numerator (Z.of_int 10_000) in + if Z.fits_int64 result then Ok (Scalar.Money.of_micros (Z.to_int64 result)) + else Error "distribution basis allocation overflow" + +let apply_distribution (state : t) ~source_instrument_id + ~destination_instrument_id ~destination_lot_size ~numerator ~denominator + ~basis_allocation_bps ~fractional_policy = + let source = position state source_instrument_id in + let destination = position state destination_instrument_id in + let same_instrument = + Id.Instrument.equal source_instrument_id destination_instrument_id + in + let* () = + if + (not same_instrument) + && (not (Scalar.Quantity.is_zero source.quantity)) + && (not (Scalar.Quantity.is_zero destination.quantity)) + && Scalar.Quantity.is_positive source.quantity + <> Scalar.Quantity.is_positive destination.quantity + then Error "distribution cannot cross an opposite destination position" + else Ok () + in + let* entitlement = + Scalar.Quantity.scale_ratio_exact source.quantity ~numerator ~denominator + in + let* delivered = + Scalar.Quantity.round_toward_zero_to_multiple entitlement + ~multiple:destination_lot_size + in + let* fractional = Scalar.Quantity.subtract entitlement delivered in + let has_fractional = not (Scalar.Quantity.is_zero fractional) in + let* cash_in_lieu = + match (has_fractional, fractional_policy) with + | false, _ -> Ok Scalar.Money.zero + | true, Corporate_action.Reject_fractional -> + Error "distribution produces a fractional entitlement" + | true, Cash_in_lieu { price; _ } -> Scalar.Money.notional price fractional + in + let* allocated_basis = + money_bps_toward_zero source.cost_basis basis_allocation_bps + in + let* source_cost_basis = + Scalar.Money.subtract source.cost_basis allocated_basis + in + let* absolute_entitlement = Scalar.Quantity.absolute entitlement in + let* absolute_delivered = Scalar.Quantity.absolute delivered in + let* delivered_basis = + if Scalar.Quantity.is_zero absolute_entitlement then Ok Scalar.Money.zero + else + Scalar.Money.proportion_toward_zero allocated_basis + ~numerator:absolute_delivered ~denominator:absolute_entitlement + in + let* fractional_basis = + Scalar.Money.subtract allocated_basis delivered_basis + in + let* source_realized = + if has_fractional then + let* delta = Scalar.Money.subtract cash_in_lieu fractional_basis in + Scalar.Money.add source.realized_pnl delta + else Ok source.realized_pnl + in + let* destination_quantity = + Scalar.Quantity.add destination.quantity delivered + in + let* destination_basis = + Scalar.Money.add destination.cost_basis delivered_basis + in + let positions = + if same_instrument then + update_position state.positions source_instrument_id + { + source with + quantity = destination_quantity; + cost_basis = destination_basis; + realized_pnl = source_realized; + } + else + update_position state.positions source_instrument_id + { + source with + cost_basis = source_cost_basis; + realized_pnl = source_realized; + } + |> fun positions -> + update_position positions destination_instrument_id + { + destination with + quantity = destination_quantity; + cost_basis = destination_basis; + } + in + let settled_source = settled_position_quantity state source_instrument_id in + let* settled_entitlement = + Scalar.Quantity.scale_ratio_exact settled_source ~numerator ~denominator + in + let* settled_delivered = + Scalar.Quantity.round_toward_zero_to_multiple settled_entitlement + ~multiple:destination_lot_size + in + let settled_destination = + settled_position_quantity state destination_instrument_id + in + let* settled_destination = + Scalar.Quantity.add settled_destination settled_delivered + in + let settled_positions = + if Scalar.Quantity.is_zero settled_destination then + Id.Instrument.Map.remove destination_instrument_id state.settled_positions + else + Id.Instrument.Map.add destination_instrument_id settled_destination + state.settled_positions + in + let state = { state with positions; settled_positions } in + let* state = + match fractional_policy with + | Corporate_action.Cash_in_lieu { currency; _ } when has_fractional -> + let* state = adjust_cash state currency cash_in_lieu in + adjust_settled_cash state currency cash_in_lieu + | Reject_fractional | Cash_in_lieu _ -> Ok state + in + Ok + ( state, + { + source_quantity = source.quantity; + destination_quantity = delivered; + fractional_quantity = fractional; + allocated_basis; + fractional_basis; + cash_in_lieu; + } ) + +let cash_out_position (state : t) ~instrument_id ~currency ~price = + let current = position state instrument_id in + let* proceeds = Scalar.Money.notional price current.quantity in + let* state = adjust_cash state currency proceeds in + let* state = adjust_settled_cash state currency proceeds in + let* realized_delta = Scalar.Money.subtract proceeds current.cost_basis in + let* realized_pnl = Scalar.Money.add current.realized_pnl realized_delta in + let updated = + { + current with + quantity = Scalar.Quantity.zero; + cost_basis = Scalar.Money.zero; + realized_pnl; + } + in + Ok + ( { + state with + positions = update_position state.positions instrument_id updated; + settled_positions = + Id.Instrument.Map.remove instrument_id state.settled_positions; + }, + current.quantity, + proceeds ) + let apply_borrow_fee (state : t) ~instrument_id ~quote_currency ~fee = let current = position state instrument_id in if not (Scalar.Quantity.is_negative current.quantity) then diff --git a/lib/account.mli b/lib/account.mli index bfbcc8f..ac4a1f5 100644 --- a/lib/account.mli +++ b/lib/account.mli @@ -127,6 +127,33 @@ val apply_cash_dividend : amount_per_unit:Scalar.Money.t -> (t, string) result +type distribution_result = { + source_quantity : Scalar.Quantity.t; + destination_quantity : Scalar.Quantity.t; + fractional_quantity : Scalar.Quantity.t; + allocated_basis : Scalar.Money.t; + fractional_basis : Scalar.Money.t; + cash_in_lieu : Scalar.Money.t; +} + +val apply_distribution : + t -> + source_instrument_id:Id.Instrument.t -> + destination_instrument_id:Id.Instrument.t -> + destination_lot_size:Scalar.Quantity.t -> + numerator:int64 -> + denominator:int64 -> + basis_allocation_bps:int -> + fractional_policy:Corporate_action.fractional_policy -> + (t * distribution_result, string) result + +val cash_out_position : + t -> + instrument_id:Id.Instrument.t -> + currency:string -> + price:Scalar.Price.t -> + (t * Scalar.Quantity.t * Scalar.Money.t, string) result + val apply_borrow_fee : t -> instrument_id:Id.Instrument.t -> diff --git a/lib/audit.ml b/lib/audit.ml index ddcfe9c..a57d669 100644 --- a/lib/audit.ml +++ b/lib/audit.ml @@ -8,6 +8,8 @@ type cancellation_reason = | Gtd_expired | Margin_call | Borrow_recall + | Instrument_halt + | Instrument_terminal type target_basis = Weights | Quantities @@ -50,6 +52,16 @@ type event = quantity : Scalar.Quantity.t; cash_amount : Scalar.Money.t; } + | Distribution_applied of { + action : Corporate_action.t; + result : Account.distribution_result; + } + | Lifecycle_applied of { + lifecycle_event : Instrument_lifecycle.event; + listing : Instrument_lifecycle.listing; + liquidated_quantity : Scalar.Quantity.t; + cash_amount : Scalar.Money.t; + } | Order_adjusted of { order : Order.t; action_id : Id.Corporate_action.t } | Fill_applied of Fill.t | Settlement_instruction_created of Settlement.instruction @@ -155,6 +167,8 @@ let cancellation_reason_to_string = function | Gtd_expired -> "gtd_expired" | Margin_call -> "margin_call" | Borrow_recall -> "borrow_recall" + | Instrument_halt -> "instrument_halt" + | Instrument_terminal -> "instrument_terminal" let target_basis_to_string = function | Weights -> "weights" @@ -171,6 +185,8 @@ let event_name = function | Order_cancelled _ -> "order_cancelled" | Split_applied _ -> "split_applied" | Cash_dividend_applied _ -> "cash_dividend_applied" + | Distribution_applied _ -> "distribution_applied" + | Lifecycle_applied _ -> "lifecycle_applied" | Order_adjusted _ -> "order_adjusted" | Fill_applied _ -> "fill_applied" | Settlement_instruction_created _ -> "settlement_instruction_created" diff --git a/lib/audit.mli b/lib/audit.mli index c016e4e..6866ce7 100644 --- a/lib/audit.mli +++ b/lib/audit.mli @@ -10,6 +10,8 @@ type cancellation_reason = | Gtd_expired | Margin_call | Borrow_recall + | Instrument_halt + | Instrument_terminal type target_basis = Weights | Quantities @@ -52,6 +54,16 @@ type event = quantity : Scalar.Quantity.t; cash_amount : Scalar.Money.t; } + | Distribution_applied of { + action : Corporate_action.t; + result : Account.distribution_result; + } + | Lifecycle_applied of { + lifecycle_event : Instrument_lifecycle.event; + listing : Instrument_lifecycle.listing; + liquidated_quantity : Scalar.Quantity.t; + cash_amount : Scalar.Money.t; + } | Order_adjusted of { order : Order.t; action_id : Id.Corporate_action.t } | Fill_applied of Fill.t | Settlement_instruction_created of Settlement.instruction diff --git a/lib/codec.ml b/lib/codec.ml index 6d48fca..10e85c3 100644 --- a/lib/codec.ml +++ b/lib/codec.ml @@ -241,6 +241,97 @@ let corporate_action_to_yojson action = `Assoc ((("type", string "cash_dividend") :: common) @ [ ("amount_per_unit", money amount_per_unit) ]) + | Corporate_action.Distribution + { + distribution_type; + destination_instrument_id; + numerator; + denominator; + basis_allocation_bps; + fractional_policy; + } -> + let fractional_policy = + match fractional_policy with + | Corporate_action.Reject_fractional -> + `Assoc [ ("policy", string "reject") ] + | Cash_in_lieu { price = value; currency } -> + `Assoc + [ + ("policy", string "cash_in_lieu"); + ("price", price value); + ("currency", string currency); + ] + in + `Assoc + (( "type", + string + (Corporate_action.distribution_type_to_string distribution_type) ) + :: common + @ [ + ( "destination_instrument_id", + instrument_id destination_instrument_id ); + ("numerator", int64 numerator); + ("denominator", int64 denominator); + ("basis_allocation_bps", `Int basis_allocation_bps); + ("fractional_policy", fractional_policy); + ]) + +let terminal_policy_to_yojson = function + | Instrument_lifecycle.Hold -> `Assoc [ ("policy", string "hold") ] + | Cash_out { price = value; currency } -> + `Assoc + [ + ("policy", string "cash_out"); + ("price", price value); + ("currency", string currency); + ] + +let lifecycle_event_to_yojson event = + let common = + [ + ( "event_id", + string (Id.Corporate_action.to_string event.Instrument_lifecycle.id) ); + ("instrument_id", instrument_id event.instrument_id); + ] + in + match event.kind with + | Instrument_lifecycle.Halt { reason } -> + `Assoc (("type", string "halt") :: ("reason", string reason) :: common) + | Resume -> `Assoc (("type", string "resume") :: common) + | Identifier_change { symbol; provider; provider_instrument_id } -> + `Assoc + ((("type", string "identifier_change") :: common) + @ [ + ("symbol", string symbol); + ("provider", string provider); + ("provider_instrument_id", string provider_instrument_id); + ]) + | Expiration { terminal_policy } -> + `Assoc + ((("type", string "expiration") :: common) + @ [ ("terminal_policy", terminal_policy_to_yojson terminal_policy) ]) + | Delisting { terminal_policy; reason } -> + `Assoc + ((("type", string "delisting") :: ("reason", string reason) :: common) + @ [ ("terminal_policy", terminal_policy_to_yojson terminal_policy) ]) + +let lifecycle_listing_to_yojson listing = + `Assoc + [ + ("instrument_id", instrument_id listing.Instrument_lifecycle.instrument_id); + ("symbol", string listing.symbol); + ("status", string (Instrument_lifecycle.status_to_string listing.status)); + ( "provider_mappings", + `List + (List.map + (fun (provider, provider_instrument_id) -> + `Assoc + [ + ("provider", string provider); + ("provider_instrument_id", string provider_instrument_id); + ]) + listing.provider_mappings) ); + ] let borrow_observation_to_yojson observation = `Assoc @@ -284,9 +375,9 @@ let versioned_market_slice_to_yojson ~contract_version market_slice = ); ] |> function - | `Assoc fields when List.mem contract_version [ "11"; "10" ] -> + | `Assoc fields when List.mem contract_version [ "12"; "11"; "10" ] -> let settlement = - if String.equal contract_version "11" then + if List.mem contract_version [ "12"; "11" ] then [ ( "settlement_failures", `List @@ -295,6 +386,16 @@ let versioned_market_slice_to_yojson ~contract_version market_slice = ] else [] in + let lifecycle = + if String.equal contract_version "12" then + [ + ( "lifecycle_events", + `List + (List.map lifecycle_event_to_yojson + market_slice.Market_slice.lifecycle_events) ); + ] + else [] + in `Assoc (fields @ [ @@ -307,7 +408,7 @@ let versioned_market_slice_to_yojson ~contract_version market_slice = (List.map cash_rate_observation_to_yojson market_slice.Market_slice.cash_rate_observations) ); ] - @ settlement) + @ settlement @ lifecycle) | json -> json let market_slice_to_yojson market_slice = @@ -319,6 +420,9 @@ let market_slice_to_yojson_v10 market_slice = let market_slice_to_yojson_v11 market_slice = versioned_market_slice_to_yojson ~contract_version:"11" market_slice +let market_slice_to_yojson_v12 market_slice = + versioned_market_slice_to_yojson ~contract_version:"12" market_slice + let request_fields request = let kind, limit_price = match request.Order.kind with @@ -422,7 +526,7 @@ let order_to_yojson_v8 order = ]) let versioned_order_to_yojson ~contract_version order = - if List.mem contract_version [ "11"; "10"; "9"; "8" ] then + if List.mem contract_version [ "12"; "11"; "10"; "9"; "8" ] then order_to_yojson_v8 order else order_to_yojson order @@ -620,7 +724,7 @@ let account_valuation_to_yojson ?(contract_version = "8") valuation = ( "cash_balances", `List (List.map - (if String.equal contract_version "11" then + (if List.mem contract_version [ "12"; "11" ] then cash_attribution_to_yojson_v11 else if String.equal contract_version "10" then cash_attribution_to_yojson_v10 @@ -629,7 +733,7 @@ let account_valuation_to_yojson ?(contract_version = "8") valuation = ( "positions", `List (List.map - (if String.equal contract_version "11" then + (if List.mem contract_version [ "12"; "11" ] then position_attribution_to_yojson_v11 else if String.equal contract_version "9" @@ -639,14 +743,14 @@ let account_valuation_to_yojson ?(contract_version = "8") valuation = valuation.positions) ); ] |> function - | `Assoc fields when List.mem contract_version [ "11"; "10"; "9" ] -> + | `Assoc fields when List.mem contract_version [ "12"; "11"; "10"; "9" ] -> let financing = - if List.mem contract_version [ "11"; "10" ] then + if List.mem contract_version [ "12"; "11"; "10" ] then [ ("cash_interest", money valuation.Account.cash_interest) ] else [] in let settlement = - if String.equal contract_version "11" then + if List.mem contract_version [ "12"; "11" ] then [ ("settled_cash", money valuation.Account.settled_cash); ("unsettled_cash", money valuation.unsettled_cash); @@ -693,7 +797,7 @@ let valuation_to_yojson ~contract_version valuation = | `Assoc fields -> let fields = fields @ [ ("margin", margin_to_yojson valuation.margin) ] in let fields = - if List.mem contract_version [ "11"; "10"; "9"; "8" ] then + if List.mem contract_version [ "12"; "11"; "10"; "9"; "8" ] then fields @ [ ( "group_exposures", @@ -795,6 +899,26 @@ let payload_to_yojson ~contract_version = function ("quantity", quantity held); ("cash_amount", money cash_amount); ] + | Audit.Distribution_applied { action; result } -> + `Assoc + [ + ("action", corporate_action_to_yojson action); + ("source_quantity", quantity result.Account.source_quantity); + ("destination_quantity", quantity result.destination_quantity); + ("fractional_quantity", quantity result.fractional_quantity); + ("allocated_basis", money result.allocated_basis); + ("fractional_basis", money result.fractional_basis); + ("cash_in_lieu", money result.cash_in_lieu); + ] + | Audit.Lifecycle_applied + { lifecycle_event; listing; liquidated_quantity; cash_amount } -> + `Assoc + [ + ("lifecycle_event", lifecycle_event_to_yojson lifecycle_event); + ("listing", lifecycle_listing_to_yojson listing); + ("liquidated_quantity", quantity liquidated_quantity); + ("cash_amount", money cash_amount); + ] | Audit.Order_adjusted { order; action_id } -> `Assoc [ @@ -802,7 +926,7 @@ let payload_to_yojson ~contract_version = function ("action_id", string (Id.Corporate_action.to_string action_id)); ] | Audit.Fill_applied fill -> - if List.mem contract_version [ "11"; "10"; "9" ] then + if List.mem contract_version [ "12"; "11"; "10"; "9" ] then fill_to_yojson_v9 fill else fill_to_yojson fill | Audit.Settlement_instruction_created instruction diff --git a/lib/codec.mli b/lib/codec.mli index 0c31a15..497651a 100644 --- a/lib/codec.mli +++ b/lib/codec.mli @@ -6,6 +6,7 @@ val bar_to_yojson : Bar.t -> Yojson.Safe.t val market_slice_to_yojson : Market_slice.t -> Yojson.Safe.t val market_slice_to_yojson_v10 : Market_slice.t -> Yojson.Safe.t val market_slice_to_yojson_v11 : Market_slice.t -> Yojson.Safe.t +val market_slice_to_yojson_v12 : Market_slice.t -> Yojson.Safe.t val order_to_yojson : Order.t -> Yojson.Safe.t val order_to_yojson_v8 : Order.t -> Yojson.Safe.t val fill_to_yojson : Fill.t -> Yojson.Safe.t diff --git a/lib/contract.ml b/lib/contract.ml index ff2231d..0bad3aa 100644 --- a/lib/contract.ml +++ b/lib/contract.ml @@ -1,11 +1,12 @@ -let version = "11" -let previous_version = "10" +let version = "12" +let previous_version = "11" let legacy_journal_version = "3" let supported_versions = [ version; previous_version; + "10"; "9"; "8"; "7"; @@ -16,8 +17,8 @@ let supported_versions = ] let is_supported version = List.mem version supported_versions -let strategy_protocol_version = "9" -let previous_strategy_protocol_version = "8" +let strategy_protocol_version = "10" +let previous_strategy_protocol_version = "9" let engine_version = "1.0.0" let strings values = `List (List.map (fun value -> `String value) values) @@ -36,6 +37,7 @@ let capabilities_to_yojson () = [ strategy_protocol_version; previous_strategy_protocol_version; + "8"; "7"; "6"; "5"; diff --git a/lib/corporate_action.ml b/lib/corporate_action.ml index 3e75b9d..68c7a98 100644 --- a/lib/corporate_action.ml +++ b/lib/corporate_action.ml @@ -1,6 +1,20 @@ type kind = | Split of { numerator : int64; denominator : int64 } | Cash_dividend of { amount_per_unit : Scalar.Money.t } + | Distribution of { + distribution_type : distribution_type; + destination_instrument_id : Id.Instrument.t; + numerator : int64; + denominator : int64; + basis_allocation_bps : int; + fractional_policy : fractional_policy; + } + +and distribution_type = Stock_dividend | Rights | Spin_off + +and fractional_policy = + | Reject_fractional + | Cash_in_lieu of { price : Scalar.Price.t; currency : string } type t = { id : Id.Corporate_action.t; @@ -20,6 +34,61 @@ let cash_dividend ~id ~instrument_id ~amount_per_unit = Error "cash dividend amount per unit must be positive" else Ok { id; instrument_id; kind = Cash_dividend { amount_per_unit } } +let valid_label value = + String.length value > 0 + && String.for_all + (fun character -> + let code = Char.code character in + code >= 0x21 && code <> 0x7f) + value + +let distribution ~id ~instrument_id ~distribution_type + ~destination_instrument_id ~numerator ~denominator ~basis_allocation_bps + ~fractional_policy = + if Int64.compare numerator 0L <= 0 || Int64.compare denominator 0L <= 0 then + Error "distribution numerator and denominator must be positive" + else if basis_allocation_bps < 0 || basis_allocation_bps > 10_000 then + Error "distribution basis allocation must be between 0 and 10000 bps" + else if + distribution_type = Stock_dividend + && not (Id.Instrument.equal instrument_id destination_instrument_id) + then Error "stock dividend destination must be its source instrument" + else if distribution_type = Stock_dividend && basis_allocation_bps <> 0 then + Error "stock dividend basis allocation must be zero" + else if + distribution_type = Stock_dividend + && Int64.compare numerator (Int64.sub Int64.max_int denominator) > 0 + then Error "stock dividend total ratio overflows" + else if + distribution_type <> Stock_dividend + && Id.Instrument.equal instrument_id destination_instrument_id + then Error "rights and spin-off destinations must differ from their source" + else + match fractional_policy with + | Cash_in_lieu { currency; _ } when not (valid_label currency) -> + Error "cash-in-lieu currency must not be empty or contain whitespace" + | Reject_fractional | Cash_in_lieu _ -> + Ok + { + id; + instrument_id; + kind = + Distribution + { + distribution_type; + destination_instrument_id; + numerator; + denominator; + basis_allocation_bps; + fractional_policy; + }; + } + +let distribution_type_to_string = function + | Stock_dividend -> "stock_dividend" + | Rights -> "rights" + | Spin_off -> "spin_off" + let compare left right = Id.Corporate_action.compare left.id right.id let pp formatter action = @@ -29,6 +98,10 @@ let pp formatter action = Printf.sprintf "split %Ld:%Ld" numerator denominator | Cash_dividend { amount_per_unit } -> "dividend " ^ Scalar.Money.to_decimal_string amount_per_unit + | Distribution { distribution_type; numerator; denominator; _ } -> + Printf.sprintf "%s %Ld:%Ld" + (distribution_type_to_string distribution_type) + numerator denominator in Format.fprintf formatter "%a %s %a" Id.Corporate_action.pp action.id kind Id.Instrument.pp action.instrument_id diff --git a/lib/corporate_action.mli b/lib/corporate_action.mli index 9d974c5..6ccb831 100644 --- a/lib/corporate_action.mli +++ b/lib/corporate_action.mli @@ -4,6 +4,20 @@ type kind = | Split of { numerator : int64; denominator : int64 } | Cash_dividend of { amount_per_unit : Scalar.Money.t } + | Distribution of { + distribution_type : distribution_type; + destination_instrument_id : Id.Instrument.t; + numerator : int64; + denominator : int64; + basis_allocation_bps : int; + fractional_policy : fractional_policy; + } + +and distribution_type = Stock_dividend | Rights | Spin_off + +and fractional_policy = + | Reject_fractional + | Cash_in_lieu of { price : Scalar.Price.t; currency : string } type t = private { id : Id.Corporate_action.t; @@ -24,5 +38,17 @@ val cash_dividend : amount_per_unit:Scalar.Money.t -> (t, string) result +val distribution : + id:Id.Corporate_action.t -> + instrument_id:Id.Instrument.t -> + distribution_type:distribution_type -> + destination_instrument_id:Id.Instrument.t -> + numerator:int64 -> + denominator:int64 -> + basis_allocation_bps:int -> + fractional_policy:fractional_policy -> + (t, string) result + +val distribution_type_to_string : distribution_type -> string val compare : t -> t -> int val pp : Format.formatter -> t -> unit diff --git a/lib/engine.ml b/lib/engine.ml index 2eb590e..f1e69fc 100644 --- a/lib/engine.ml +++ b/lib/engine.ml @@ -55,6 +55,8 @@ let config_v11 ~contract_version ~risk ~venue_calendars ~execution_model ~execution ~financing:(Some financing) ~settlement:(Some settlement) ~max_internal_events +let config_v12 = config_v11 + let valid_sha256 value = String.length value = 64 && String.for_all @@ -88,6 +90,7 @@ module Interactive = struct settlement_instructions : Settlement.instruction list; initial_portfolio : Initial_portfolio.t option; applied_action_ids : Id.Corporate_action.Set.t; + lifecycle : Instrument_lifecycle.t; desired_targets : desired_targets option; liquidation_pending : bool; account : Account.t; @@ -133,6 +136,9 @@ module Interactive = struct let create_state ~run_id ~scenario_sha256 ~config ~account ~latest_marks ~latest_fx_rates ~initial_portfolio = + let* lifecycle = + Instrument_lifecycle.create (Risk.instruments config.risk) + in Ok { run_id; @@ -152,6 +158,7 @@ module Interactive = struct settlement_instructions = []; initial_portfolio; applied_action_ids = Id.Corporate_action.Set.empty; + lifecycle; desired_targets = None; liquidation_pending = false; account; @@ -370,6 +377,13 @@ module Interactive = struct ~engine_sequence:order_sequence in match + let* () = + if + Instrument_lifecycle.is_tradable reduction.state.lifecycle + request.Order.instrument_id + then Ok () + else Error "instrument is not tradable" + in let* () = match request.Order.time_in_force with | Order.Day { venue_id; calendar_id } -> ( @@ -612,7 +626,8 @@ module Interactive = struct | None -> Error "split target refers to an unknown instrument" | Some quantity -> ( match action.kind with - | Corporate_action.Cash_dividend _ -> Ok desired + | Corporate_action.Cash_dividend _ | Corporate_action.Distribution _ -> + Ok desired | Corporate_action.Split { numerator; denominator } -> let* quantity = Scalar.Quantity.scale_ratio_exact quantity ~numerator ~denominator @@ -731,6 +746,95 @@ module Interactive = struct emit reduction (Audit.Cash_dividend_applied { action; quantity; cash_amount }) + let apply_distribution_action reduction action distribution_type + destination_instrument_id numerator denominator basis_allocation_bps + fractional_policy = + let* destination = + match + Risk.instrument reduction.state.config.risk destination_instrument_id + with + | Some value -> Ok value + | None -> Error "distribution refers to an unknown destination instrument" + in + let* () = + match fractional_policy with + | Corporate_action.Reject_fractional -> Ok () + | Cash_in_lieu { currency; _ } -> + if String.equal currency destination.quote_currency then Ok () + else + Error "cash-in-lieu currency must equal destination quote currency" + in + let* account, result = + Account.apply_distribution reduction.state.account + ~source_instrument_id:action.Corporate_action.instrument_id + ~destination_instrument_id ~destination_lot_size:destination.lot_size + ~numerator ~denominator ~basis_allocation_bps ~fractional_policy + in + let reduction = + { reduction with state = { reduction.state with account } } + in + let* reduction, distribution_event_id = + emit_with_id reduction (Audit.Distribution_applied { action; result }) + in + match distribution_type with + | Corporate_action.Stock_dividend -> + let total_numerator = Int64.add numerator denominator in + let* desired_targets = + match reduction.state.desired_targets with + | None -> Ok None + | Some desired -> ( + match + Id.Instrument.Map.find_opt action.instrument_id + desired.quantities + with + | None -> + Error "stock-dividend target refers to an unknown instrument" + | Some quantity -> + let* entitlement = + Scalar.Quantity.scale_ratio_exact quantity ~numerator + ~denominator + in + let* delivered = + Scalar.Quantity.round_toward_zero_to_multiple entitlement + ~multiple:destination.lot_size + in + let* quantity = Scalar.Quantity.add quantity delivered in + Ok + (Some + { + quantities = + Id.Instrument.Map.add action.instrument_id quantity + desired.quantities; + cause_ids = distribution_event_id :: desired.cause_ids; + })) + in + let active = + Oms.active_for_instrument reduction.state.oms action.instrument_id + in + let* updated_event_ids = + event_ids_after reduction.state (List.length active) + in + let* oms, adjusted = + Oms.adjust_for_split reduction.state.oms + ~instrument_id:action.instrument_id ~updated_event_ids + ~numerator:total_numerator ~denominator + in + let reduction = + { + reduction with + state = { reduction.state with oms; desired_targets }; + } + in + List.fold_left + (fun result order -> + let* reduction = result in + emit + (with_causes reduction + [ order.Order.created_event_id; distribution_event_id ]) + (Audit.Order_adjusted { order; action_id = action.id })) + (Ok reduction) adjusted + | Rights | Spin_off -> Ok reduction + let apply_corporate_actions reduction actions = List.fold_left (fun result action -> @@ -741,9 +845,116 @@ module Interactive = struct | Split { numerator; denominator } -> apply_split_action reduction action numerator denominator | Cash_dividend { amount_per_unit } -> - apply_dividend_action reduction action amount_per_unit) + apply_dividend_action reduction action amount_per_unit + | Distribution + { + distribution_type; + destination_instrument_id; + numerator; + denominator; + basis_allocation_bps; + fractional_policy; + } -> + apply_distribution_action reduction action distribution_type + destination_instrument_id numerator denominator + basis_allocation_bps fractional_policy) (Ok reduction) actions + let replace_desired_quantity state instrument_id quantity cause_id = + match state.desired_targets with + | None -> state + | Some desired -> + { + state with + desired_targets = + Some + { + quantities = + Id.Instrument.Map.add instrument_id quantity + desired.quantities; + cause_ids = cause_id :: desired.cause_ids; + }; + } + + let apply_lifecycle_event reduction + (lifecycle_event : Instrument_lifecycle.event) = + let instrument_id = lifecycle_event.Instrument_lifecycle.instrument_id in + let* lifecycle = + Instrument_lifecycle.apply reduction.state.lifecycle lifecycle_event + in + let* instrument = + match Risk.instrument reduction.state.config.risk instrument_id with + | Some instrument -> Ok instrument + | None -> Error "lifecycle event refers to an unknown instrument" + in + let* account, liquidated_quantity, cash_amount = + match lifecycle_event.kind with + | Instrument_lifecycle.Expiration { terminal_policy } + | Delisting { terminal_policy; _ } -> ( + match terminal_policy with + | Instrument_lifecycle.Hold -> + Ok + ( reduction.state.account, + Scalar.Quantity.zero, + Scalar.Money.zero ) + | Cash_out { price; currency } -> + if not (String.equal currency instrument.quote_currency) then + Error + "terminal cash-out currency must equal instrument quote \ + currency" + else + Account.cash_out_position reduction.state.account ~instrument_id + ~currency ~price) + | Halt _ | Resume | Identifier_change _ -> + Ok (reduction.state.account, Scalar.Quantity.zero, Scalar.Money.zero) + in + let listing = + Instrument_lifecycle.listing lifecycle instrument_id |> Option.get + in + let reduction = + { reduction with state = { reduction.state with lifecycle; account } } + in + let* reduction, lifecycle_event_id = + emit_with_id reduction + (Audit.Lifecycle_applied + { lifecycle_event; listing; liquidated_quantity; cash_amount }) + in + let cancellation_reason, target_quantity = + match lifecycle_event.kind with + | Instrument_lifecycle.Halt _ -> + ( Some Audit.Instrument_halt, + Account.position_quantity account instrument_id ) + | Expiration _ | Delisting _ -> + ( Some Audit.Instrument_terminal, + Account.position_quantity account instrument_id ) + | Resume | Identifier_change _ -> (None, Scalar.Quantity.zero) + in + let state = + match cancellation_reason with + | None -> reduction.state + | Some _ -> + replace_desired_quantity reduction.state instrument_id target_quantity + lifecycle_event_id + in + let reduction = { reduction with state } in + match cancellation_reason with + | None -> Ok reduction + | Some reason -> + let ids = + Oms.active_for_instrument reduction.state.oms instrument_id + |> List.map (fun order -> order.Order.id) + in + cancel_orders (with_causes reduction [ lifecycle_event_id ]) ~reason ids + + let apply_lifecycle_events reduction events = + List.fold_left + (fun result lifecycle_event -> + let* reduction = result in + apply_lifecycle_event + (with_causes reduction (Option.to_list reduction.slice_event_id)) + lifecycle_event) + (Ok reduction) events + let borrow_fee ~notional ~bps span = if bps = 0 || Scalar.Money.equal notional Scalar.Money.zero then Ok Scalar.Money.zero @@ -1125,6 +1336,12 @@ module Interactive = struct (Scalar.Quantity.is_multiple target.quantity ~lot:instrument.Instrument.lot_size) then Error "target quantity is not aligned to its instrument lot" + else if + (not + (Instrument_lifecycle.is_tradable state.lifecycle + target.instrument_id)) + && not (Scalar.Quantity.is_zero target.quantity) + then Error "non-tradable instrument target must be zero" else let* () = Risk.check_position_for state.config.risk target.instrument_id @@ -1189,6 +1406,14 @@ module Interactive = struct ~weight:target.weight ~price:bar.close_price ~lot_size:instrument.lot_size in + let* () = + if + Instrument_lifecycle.is_tradable state.lifecycle + target.instrument_id + || Scalar.Quantity.is_zero quantity + then Ok () + else Error "non-tradable instrument target must be zero" + in let* () = Risk.check_position_for state.config.risk target.instrument_id quantity @@ -1346,6 +1571,23 @@ module Interactive = struct (Id.Corporate_action.Set.mem action.id state.applied_action_ids)) market_slice.corporate_actions in + let lifecycle_valid = + List.for_all + (fun (lifecycle_event : Instrument_lifecycle.event) -> + Option.is_some + (Risk.instrument state.config.risk + lifecycle_event.Instrument_lifecycle.instrument_id) + && (not + (Id.Corporate_action.Set.mem lifecycle_event.id + state.applied_action_ids)) + && not + (List.exists + (fun action -> + Id.Corporate_action.equal action.Corporate_action.id + lifecycle_event.id) + market_slice.corporate_actions)) + market_slice.lifecycle_events + in let borrow_observations_valid = List.for_all (fun (observation : Financing.borrow_observation) -> @@ -1407,6 +1649,8 @@ module Interactive = struct then Error "market slice base-currency FX rate must equal one" else if not actions_valid then Error "corporate action is unknown or was already applied" + else if not lifecycle_valid then + Error "lifecycle event is unknown, duplicated, or was already applied" else if not borrow_observations_valid then Error "borrow observations must be known and advance effective time" else if not cash_observations_valid then @@ -2081,6 +2325,12 @@ module Interactive = struct (fun ids action -> Id.Corporate_action.Set.add action.Corporate_action.id ids) state.applied_action_ids market_slice.Market_slice.corporate_actions + |> fun ids -> + List.fold_left + (fun ids (lifecycle_event : Instrument_lifecycle.event) -> + Id.Corporate_action.Set.add lifecycle_event.Instrument_lifecycle.id + ids) + ids market_slice.lifecycle_events in let latest_bars = List.fold_left @@ -2153,8 +2403,11 @@ module Interactive = struct let run market_slice reduction = let* reduction = cancel_expired_gtd reduction market_slice in let* reduction = process_settlements reduction market_slice in - apply_corporate_actions reduction - market_slice.Market_slice.corporate_actions + let* reduction = + apply_corporate_actions reduction + market_slice.Market_slice.corporate_actions + in + apply_lifecycle_events reduction market_slice.lifecycle_events end module Borrow_phase = struct diff --git a/lib/engine.mli b/lib/engine.mli index 3483edd..50c9eaa 100644 --- a/lib/engine.mli +++ b/lib/engine.mli @@ -40,6 +40,17 @@ val config_v11 : max_internal_events:int -> (config, string) result +val config_v12 : + contract_version:string -> + risk:Risk.t -> + venue_calendars:Venue_calendar.t list -> + execution_model:Execution_model.t -> + execution:Execution.t -> + financing:Financing.policy -> + settlement:Settlement.policy -> + max_internal_events:int -> + (config, string) result + module Interactive : sig type t type progress diff --git a/lib/execution_model.ml b/lib/execution_model.ml index c4c39d3..ad8097c 100644 --- a/lib/execution_model.ml +++ b/lib/execution_model.ml @@ -37,7 +37,7 @@ let completed_bar_v1_contract = version = "2"; previous_versions = [ "1" ]; scenario_contract_versions = - [ "11"; "10"; "9"; "8"; "7"; "6"; "5"; "4"; "3" ]; + [ "12"; "11"; "10"; "9"; "8"; "7"; "6"; "5"; "4"; "3" ]; required_fields = [ "version"; "participation_bps"; "fee_schedules" ]; legacy_required_fields = [ "version"; "participation_bps"; "fixed_fee"; "fee_bps" ]; diff --git a/lib/external_replay.ml b/lib/external_replay.ml index d76f87e..e72a720 100644 --- a/lib/external_replay.ml +++ b/lib/external_replay.ml @@ -105,9 +105,14 @@ let create_runner ~contract_version ~run_id ~scenario_sha256 ~risk Engine.config_v10 ~contract_version ~risk ~venue_calendars ~execution_model ~execution ~financing ~max_internal_events | Some financing, Some settlement -> - Engine.config_v11 ~contract_version ~risk ~venue_calendars - ~execution_model ~execution ~financing ~settlement - ~max_internal_events + if String.equal contract_version "12" then + Engine.config_v12 ~contract_version ~risk ~venue_calendars + ~execution_model ~execution ~financing ~settlement + ~max_internal_events + else + Engine.config_v11 ~contract_version ~risk ~venue_calendars + ~execution_model ~execution ~financing ~settlement + ~max_internal_events | None, Some _ -> Error "settlement requires financing configuration") |> reducer_result in diff --git a/lib/instrument_lifecycle.ml b/lib/instrument_lifecycle.ml new file mode 100644 index 0000000..756aeb1 --- /dev/null +++ b/lib/instrument_lifecycle.ml @@ -0,0 +1,136 @@ +type terminal_policy = + | Hold + | Cash_out of { price : Scalar.Price.t; currency : string } + +type kind = + | Halt of { reason : string } + | Resume + | Identifier_change of { + symbol : string; + provider : string; + provider_instrument_id : string; + } + | Expiration of { terminal_policy : terminal_policy } + | Delisting of { terminal_policy : terminal_policy; reason : string } + +type event = { + id : Id.Corporate_action.t; + instrument_id : Id.Instrument.t; + kind : kind; +} + +type status = Tradable | Halted | Expired | Delisted + +type listing = { + instrument_id : Id.Instrument.t; + symbol : string; + provider_mappings : (string * string) list; + status : status; +} + +type t = listing Id.Instrument.Map.t + +let valid_label value = + String.length value > 0 + && String.for_all + (fun character -> + let code = Char.code character in + code >= 0x21 && code <> 0x7f) + value + +let validate_terminal_policy = function + | Hold -> Ok () + | Cash_out { currency; _ } -> + if valid_label currency then Ok () + else + Error + "terminal cash-out currency must not be empty or contain whitespace" + +let create_event ~id ~instrument_id ~kind = + match kind with + | (Halt { reason } | Delisting { reason; _ }) when not (valid_label reason) -> + Error "lifecycle reason must not be empty or contain whitespace" + | Identifier_change { symbol; provider; provider_instrument_id } + when not + (valid_label symbol && valid_label provider + && valid_label provider_instrument_id) -> + Error "identifier-change values must not be empty or contain whitespace" + | Expiration { terminal_policy } | Delisting { terminal_policy; _ } -> + Result.map + (fun () -> { id; instrument_id; kind }) + (validate_terminal_policy terminal_policy) + | Halt _ | Resume | Identifier_change _ -> Ok { id; instrument_id; kind } + +let compare_event left right = Id.Corporate_action.compare left.id right.id + +let create instruments = + List.fold_left + (fun result (instrument : Instrument.t) -> + Result.bind result (fun state -> + if Id.Instrument.Map.mem instrument.id state then + Error "lifecycle catalog instrument IDs must be unique" + else + Ok + (Id.Instrument.Map.add instrument.id + { + instrument_id = instrument.id; + symbol = instrument.symbol; + provider_mappings = []; + status = Tradable; + } + state))) + (Ok Id.Instrument.Map.empty) instruments + +let listing state instrument_id = Id.Instrument.Map.find_opt instrument_id state + +let is_tradable state instrument_id = + match listing state instrument_id with + | Some { status = Tradable; _ } -> true + | Some _ | None -> false + +let apply state (event : event) = + match listing state event.instrument_id with + | None -> Error "lifecycle event refers to an unknown instrument" + | Some current -> + let result = + match (current.status, event.kind) with + | (Expired | Delisted), _ -> + Error "terminal instrument cannot accept another lifecycle event" + | Tradable, Halt _ -> Ok { current with status = Halted } + | Halted, Resume -> Ok { current with status = Tradable } + | Halted, Halt _ -> Error "halted instrument cannot be halted again" + | Tradable, Resume -> Error "tradable instrument cannot be resumed" + | (Tradable | Halted), Identifier_change change -> + let mappings = + (change.provider, change.provider_instrument_id) + :: List.remove_assoc change.provider current.provider_mappings + |> List.sort (fun (left, _) (right, _) -> + String.compare left right) + in + Ok + { + current with + symbol = change.symbol; + provider_mappings = mappings; + } + | (Tradable | Halted), Expiration _ -> + Ok { current with status = Expired } + | (Tradable | Halted), Delisting _ -> + Ok { current with status = Delisted } + in + Result.map + (fun updated -> Id.Instrument.Map.add event.instrument_id updated state) + result + +let status_to_string = function + | Tradable -> "tradable" + | Halted -> "halted" + | Expired -> "expired" + | Delisted -> "delisted" + +let kind_to_string = function + | Halt _ -> "halt" + | Resume -> "resume" + | Identifier_change _ -> "identifier_change" + | Expiration _ -> "expiration" + | Delisting _ -> "delisting" diff --git a/lib/instrument_lifecycle.mli b/lib/instrument_lifecycle.mli new file mode 100644 index 0000000..8bb75be --- /dev/null +++ b/lib/instrument_lifecycle.mli @@ -0,0 +1,47 @@ +(** Deterministic mutable-listing state keyed by stable instrument identity. *) + +type terminal_policy = + | Hold + | Cash_out of { price : Scalar.Price.t; currency : string } + +type kind = + | Halt of { reason : string } + | Resume + | Identifier_change of { + symbol : string; + provider : string; + provider_instrument_id : string; + } + | Expiration of { terminal_policy : terminal_policy } + | Delisting of { terminal_policy : terminal_policy; reason : string } + +type event = private { + id : Id.Corporate_action.t; + instrument_id : Id.Instrument.t; + kind : kind; +} + +type status = Tradable | Halted | Expired | Delisted + +type listing = private { + instrument_id : Id.Instrument.t; + symbol : string; + provider_mappings : (string * string) list; + status : status; +} + +type t + +val create_event : + id:Id.Corporate_action.t -> + instrument_id:Id.Instrument.t -> + kind:kind -> + (event, string) result + +val compare_event : event -> event -> int +val create : Instrument.t list -> (t, string) result +val listing : t -> Id.Instrument.t -> listing option +val is_tradable : t -> Id.Instrument.t -> bool +val apply : t -> event -> (t, string) result +val status_to_string : status -> string +val kind_to_string : kind -> string diff --git a/lib/market_slice.ml b/lib/market_slice.ml index 36e2d07..9f3fdbe 100644 --- a/lib/market_slice.ml +++ b/lib/market_slice.ml @@ -9,6 +9,7 @@ type t = { bars : Bar.t list; fx_rates : fx_mark list; corporate_actions : Corporate_action.t list; + lifecycle_events : Instrument_lifecycle.event list; borrow_observations : Financing.borrow_observation list; cash_rate_observations : Financing.cash_rate_observation list; settlement_failures : Settlement.failure list; @@ -30,9 +31,9 @@ let fx_mark ~currency ~rate = let compare_bar left right = Id.Instrument.compare left.Bar.instrument_id right.Bar.instrument_id -let create_v11 ~slice_sequence ~start_at ~end_at ~available_at ~received_at +let create_v12 ~slice_sequence ~start_at ~end_at ~available_at ~received_at ~bars ~fx_rates ~corporate_actions ~borrow_observations - ~cash_rate_observations ~settlement_failures = + ~cash_rate_observations ~settlement_failures ~lifecycle_events = if Int64.compare slice_sequence 0L <= 0 then Error "market slice sequence must be positive" else if Ptime.compare start_at end_at >= 0 then @@ -64,6 +65,16 @@ let create_v11 ~slice_sequence ~start_at ~end_at ~available_at ~received_at let corporate_actions = List.sort Corporate_action.compare corporate_actions in + let lifecycle_events = + List.sort Instrument_lifecycle.compare_event lifecycle_events + in + let rec unique_lifecycle = function + | [] | [ _ ] -> true + | left :: (right :: _ as remaining) -> + (not + (Id.Corporate_action.equal left.Instrument_lifecycle.id right.id)) + && unique_lifecycle remaining + in let rec unique_actions = function | [] | [ _ ] -> true | left :: (right :: _ as remaining) -> @@ -119,6 +130,8 @@ let create_v11 ~slice_sequence ~start_at ~end_at ~available_at ~received_at Error "market slice must contain one FX rate per currency" else if not (unique_actions corporate_actions) then Error "market slice corporate action IDs must be unique" + else if not (unique_lifecycle lifecycle_events) then + Error "market slice lifecycle event IDs must be unique" else if not (unique_borrow borrow_observations) then Error "market slice borrow observation instrument IDs must be unique" else if not (unique_cash_rate cash_rate_observations) then @@ -136,11 +149,19 @@ let create_v11 ~slice_sequence ~start_at ~end_at ~available_at ~received_at bars; fx_rates; corporate_actions; + lifecycle_events; borrow_observations; cash_rate_observations; settlement_failures; } +let create_v11 ~slice_sequence ~start_at ~end_at ~available_at ~received_at + ~bars ~fx_rates ~corporate_actions ~borrow_observations + ~cash_rate_observations ~settlement_failures = + create_v12 ~slice_sequence ~start_at ~end_at ~available_at ~received_at ~bars + ~fx_rates ~corporate_actions ~borrow_observations ~cash_rate_observations + ~settlement_failures ~lifecycle_events:[] + let create_v10 ~slice_sequence ~start_at ~end_at ~available_at ~received_at ~bars ~fx_rates ~corporate_actions ~borrow_observations ~cash_rate_observations = @@ -170,10 +191,12 @@ let compare_replay_order left right = let pp formatter state = Format.fprintf formatter - "slice[%Ld] bars=%d fx=%d actions=%d borrow=%d cash_rates=%d failures=%d" + "slice[%Ld] bars=%d fx=%d actions=%d lifecycle=%d borrow=%d cash_rates=%d \ + failures=%d" state.slice_sequence (List.length state.bars) (List.length state.fx_rates) (List.length state.corporate_actions) + (List.length state.lifecycle_events) (List.length state.borrow_observations) (List.length state.cash_rate_observations) (List.length state.settlement_failures) diff --git a/lib/market_slice.mli b/lib/market_slice.mli index 037690a..329ecd1 100644 --- a/lib/market_slice.mli +++ b/lib/market_slice.mli @@ -14,6 +14,7 @@ type t = private { bars : Bar.t list; fx_rates : fx_mark list; corporate_actions : Corporate_action.t list; + lifecycle_events : Instrument_lifecycle.event list; borrow_observations : Financing.borrow_observation list; cash_rate_observations : Financing.cash_rate_observation list; settlement_failures : Settlement.failure list; @@ -57,6 +58,21 @@ val create_v11 : settlement_failures:Settlement.failure list -> (t, string) result +val create_v12 : + slice_sequence:int64 -> + start_at:Ptime.t -> + end_at:Ptime.t -> + available_at:Ptime.t -> + received_at:Ptime.t -> + bars:Bar.t list -> + fx_rates:fx_mark list -> + corporate_actions:Corporate_action.t list -> + borrow_observations:Financing.borrow_observation list -> + cash_rate_observations:Financing.cash_rate_observation list -> + settlement_failures:Settlement.failure list -> + lifecycle_events:Instrument_lifecycle.event list -> + (t, string) result + val bar : t -> Id.Instrument.t -> Bar.t option val fx_rate : t -> string -> Scalar.Price.t option val compare_replay_order : t -> t -> int diff --git a/lib/replay.ml b/lib/replay.ml index f0c6332..baff101 100644 --- a/lib/replay.ml +++ b/lib/replay.ml @@ -78,8 +78,14 @@ let engine_config ~contract_version ~risk ~venue_calendars ~execution_model Engine.config_v10 ~contract_version ~risk ~venue_calendars ~execution_model ~execution ~financing ~max_internal_events | Some financing, Some settlement -> - Engine.config_v11 ~contract_version ~risk ~venue_calendars - ~execution_model ~execution ~financing ~settlement ~max_internal_events + if String.equal contract_version "12" then + Engine.config_v12 ~contract_version ~risk ~venue_calendars + ~execution_model ~execution ~financing ~settlement + ~max_internal_events + else + Engine.config_v11 ~contract_version ~risk ~venue_calendars + ~execution_model ~execution ~financing ~settlement + ~max_internal_events | None, Some _ -> Error "settlement requires financing configuration" let run ~scenario_sha256 ?journal_path ?(durability = Artifact_writer.Buffered) diff --git a/lib/scenario.ml b/lib/scenario.ml index babd019..92fd715 100644 --- a/lib/scenario.ml +++ b/lib/scenario.ml @@ -554,7 +554,7 @@ let parse_v7_risk base_currency instruments json = ~max_gross_exposure ~max_leverage ~short_borrow_bps let parse_risk ~contract_version base_currency instruments json = - if List.mem contract_version [ "11"; "10"; "9"; "8"; "7" ] then + if List.mem contract_version [ "12"; "11"; "10"; "9"; "8"; "7" ] then parse_v7_risk base_currency instruments json else parse_legacy_risk base_currency instruments json @@ -746,8 +746,8 @@ let parse_versioned_execution ~contract_version ~instruments json = Ok (execution_model, execution) let parse_execution ~contract_version ~instruments json = - if List.mem contract_version [ "11"; "10"; "9"; "8"; "7"; "6"; "5" ] then - parse_versioned_execution ~contract_version ~instruments json + if List.mem contract_version [ "12"; "11"; "10"; "9"; "8"; "7"; "6"; "5" ] + then parse_versioned_execution ~contract_version ~instruments json else parse_legacy_execution ~contract_version json let parse_side json = @@ -795,7 +795,7 @@ let parse_portfolio_intent ~name ~parse_target make json = Ok (make targets) let parse_submit_intent ~contract_version json = - let versioned = List.mem contract_version [ "11"; "10"; "9"; "8" ] in + let versioned = List.mem contract_version [ "12"; "11"; "10"; "9"; "8" ] in let* fields = object_fields ~name:"submit_order intent" ~expected: @@ -1115,8 +1115,198 @@ let parse_corporate_action json = let* amount_json = field fields "amount_per_unit" in let* amount_per_unit = parse_money ~name:"amount_per_unit" amount_json in Corporate_action.cash_dividend ~id ~instrument_id ~amount_per_unit + | ("stock_dividend" | "rights" | "spin_off") as distribution_name -> + let* () = + object_fields ~name:"distribution corporate action" + ~expected: + [ + "type"; + "action_id"; + "instrument_id"; + "destination_instrument_id"; + "numerator"; + "denominator"; + "basis_allocation_bps"; + "fractional_policy"; + ] + json + |> Result.map (fun _ -> ()) + in + let* destination_json = field fields "destination_instrument_id" in + let* destination_instrument_id = + parse_id Id.Instrument.of_string ~name:"destination_instrument_id" + destination_json + in + let* numerator = + Result.bind (field fields "numerator") + (parse_int64 ~name:"distribution numerator") + in + let* denominator = + Result.bind + (field fields "denominator") + (parse_int64 ~name:"distribution denominator") + in + let* basis_allocation_bps = + Result.bind + (field fields "basis_allocation_bps") + (integer ~name:"basis_allocation_bps") + in + let* fractional_json = field fields "fractional_policy" in + let* fractional_fields = + match fractional_json with + | `Assoc fields -> Ok fields + | _ -> Error "fractional_policy must be a JSON object" + in + let* policy_name = + Result.bind + (field fractional_fields "policy") + (string ~name:"fractional policy") + in + let* fractional_policy = + match policy_name with + | "reject" -> + object_fields ~name:"reject fractional policy" + ~expected:[ "policy" ] fractional_json + |> Result.map (fun _ -> Corporate_action.Reject_fractional) + | "cash_in_lieu" -> + let* () = + object_fields ~name:"cash-in-lieu fractional policy" + ~expected:[ "policy"; "price"; "currency" ] + fractional_json + |> Result.map (fun _ -> ()) + in + let* price = + Result.bind + (field fractional_fields "price") + (parse_price ~name:"cash-in-lieu price") + in + let* currency = + Result.bind + (field fractional_fields "currency") + (string ~name:"cash-in-lieu currency") + in + Ok (Corporate_action.Cash_in_lieu { price; currency }) + | _ -> Error "fractional policy must be reject or cash_in_lieu" + in + let distribution_type = + match distribution_name with + | "stock_dividend" -> Corporate_action.Stock_dividend + | "rights" -> Rights + | "spin_off" -> Spin_off + | _ -> assert false + in + Corporate_action.distribution ~id ~instrument_id ~distribution_type + ~destination_instrument_id ~numerator ~denominator ~basis_allocation_bps + ~fractional_policy | _ -> Error "unsupported corporate action type" +let parse_terminal_policy json = + let* fields = + match json with + | `Assoc fields -> Ok fields + | _ -> Error "terminal_policy must be a JSON object" + in + let* policy = + Result.bind (field fields "policy") (string ~name:"terminal policy") + in + match policy with + | "hold" -> + object_fields ~name:"hold terminal policy" ~expected:[ "policy" ] json + |> Result.map (fun _ -> Instrument_lifecycle.Hold) + | "cash_out" -> + let* () = + object_fields ~name:"cash-out terminal policy" + ~expected:[ "policy"; "price"; "currency" ] + json + |> Result.map (fun _ -> ()) + in + let* price = + Result.bind (field fields "price") (parse_price ~name:"terminal price") + in + let* currency = + Result.bind (field fields "currency") (string ~name:"terminal currency") + in + Ok (Instrument_lifecycle.Cash_out { price; currency }) + | _ -> Error "terminal policy must be hold or cash_out" + +let parse_lifecycle_event json = + let* fields = + match json with + | `Assoc fields -> Ok fields + | _ -> Error "lifecycle event must be a JSON object" + in + let* kind_name = + Result.bind (field fields "type") (string ~name:"lifecycle event type") + in + let* id = + Result.bind (field fields "event_id") + (parse_id Id.Corporate_action.of_string ~name:"event_id") + in + let* instrument_id = + Result.bind + (field fields "instrument_id") + (parse_id Id.Instrument.of_string ~name:"instrument_id") + in + let* kind = + match kind_name with + | "halt" -> + let* () = + object_fields ~name:"halt lifecycle event" + ~expected:[ "type"; "event_id"; "instrument_id"; "reason" ] + json + |> Result.map (fun _ -> ()) + in + Result.bind (field fields "reason") (string ~name:"halt reason") + |> Result.map (fun reason -> Instrument_lifecycle.Halt { reason }) + | "resume" -> + object_fields ~name:"resume lifecycle event" + ~expected:[ "type"; "event_id"; "instrument_id" ] + json + |> Result.map (fun _ -> Instrument_lifecycle.Resume) + | "identifier_change" -> + let* () = + object_fields ~name:"identifier-change lifecycle event" + ~expected: + [ + "type"; + "event_id"; + "instrument_id"; + "symbol"; + "provider"; + "provider_instrument_id"; + ] + json + |> Result.map (fun _ -> ()) + in + let text name = Result.bind (field fields name) (string ~name) in + let* symbol = text "symbol" in + let* provider = text "provider" in + let* provider_instrument_id = text "provider_instrument_id" in + Ok + (Instrument_lifecycle.Identifier_change + { symbol; provider; provider_instrument_id }) + | "expiration" | "delisting" -> + let delisting = String.equal kind_name "delisting" in + let expected = + [ "type"; "event_id"; "instrument_id"; "terminal_policy" ] + @ if delisting then [ "reason" ] else [] + in + let* () = + object_fields ~name:"terminal lifecycle event" ~expected json + |> Result.map (fun _ -> ()) + in + let* terminal_policy = + Result.bind (field fields "terminal_policy") parse_terminal_policy + in + if delisting then + Result.bind (field fields "reason") (string ~name:"delisting reason") + |> Result.map (fun reason -> + Instrument_lifecycle.Delisting { terminal_policy; reason }) + else Ok (Instrument_lifecycle.Expiration { terminal_policy }) + | _ -> Error "unsupported lifecycle event type" + in + Instrument_lifecycle.create_event ~id ~instrument_id ~kind + let parse_financing json = let* fields = object_fields ~name:"financing policy" @@ -1342,12 +1532,16 @@ let parse_cash_rate_observation json = let parse_slice ~contract_version json = let financing_fields = - if List.mem contract_version [ "11"; "10" ] then + if List.mem contract_version [ "12"; "11"; "10" ] then [ "borrow_observations"; "cash_rate_observations" ] else [] in let settlement_fields = - if String.equal contract_version "11" then [ "settlement_failures" ] else [] + if List.mem contract_version [ "12"; "11" ] then [ "settlement_failures" ] + else [] + in + let lifecycle_fields = + if String.equal contract_version "12" then [ "lifecycle_events" ] else [] in let* fields = object_fields ~name:"market slice" @@ -1362,7 +1556,7 @@ let parse_slice ~contract_version json = "fx_rates"; "corporate_actions"; ] - @ financing_fields @ settlement_fields) + @ financing_fields @ settlement_fields @ lifecycle_fields) json in let* sequence_json = field fields "slice_sequence" in @@ -1384,7 +1578,7 @@ let parse_slice ~contract_version json = let* actions_json = field fields "corporate_actions" in let* actions_json = list ~name:"corporate_actions" actions_json in let* corporate_actions = map_list parse_corporate_action actions_json in - if List.mem contract_version [ "11"; "10" ] then + if List.mem contract_version [ "12"; "11"; "10" ] then let* borrow_json = Result.bind (field fields "borrow_observations") @@ -1399,7 +1593,7 @@ let parse_slice ~contract_version json = let* cash_rate_observations = map_list parse_cash_rate_observation cash_json in - if String.equal contract_version "11" then + if List.mem contract_version [ "12"; "11" ] then let* failures_json = Result.bind (field fields "settlement_failures") @@ -1408,9 +1602,20 @@ let parse_slice ~contract_version json = let* settlement_failures = map_list parse_settlement_failure failures_json in - Market_slice.create_v11 ~slice_sequence ~start_at ~end_at ~available_at - ~received_at ~bars ~fx_rates ~corporate_actions ~borrow_observations - ~cash_rate_observations ~settlement_failures + if String.equal contract_version "12" then + let* lifecycle_json = + Result.bind + (field fields "lifecycle_events") + (list ~name:"lifecycle_events") + in + let* lifecycle_events = map_list parse_lifecycle_event lifecycle_json in + Market_slice.create_v12 ~slice_sequence ~start_at ~end_at ~available_at + ~received_at ~bars ~fx_rates ~corporate_actions ~borrow_observations + ~cash_rate_observations ~settlement_failures ~lifecycle_events + else + Market_slice.create_v11 ~slice_sequence ~start_at ~end_at ~available_at + ~received_at ~bars ~fx_rates ~corporate_actions ~borrow_observations + ~cash_rate_observations ~settlement_failures else Market_slice.create_v10 ~slice_sequence ~start_at ~end_at ~available_at ~received_at ~bars ~fx_rates ~corporate_actions ~borrow_observations @@ -1450,7 +1655,7 @@ let construct_header ~root ~contract_path ~contract_version |> at (child root "base_currency") in let* initial_cash, initial_portfolio = - if List.mem contract_version [ "11"; "10"; "9"; "8"; "7"; "6" ] then + if List.mem contract_version [ "12"; "11"; "10"; "9"; "8"; "7"; "6" ] then let* portfolio = parse_initial_portfolio ~base_currency shape.initial_state |> at (child root "initial_portfolio") @@ -1512,21 +1717,22 @@ let construct_header ~root ~contract_path ~contract_version in let* financing = match (contract_version, shape.financing) with - | ("11" | "10"), Some json -> + | ("12" | "11" | "10"), Some json -> parse_financing json |> at (child root "financing") - | ("11" | "10"), None -> + | ("12" | "11" | "10"), None -> Error "missing financing policy" |> at (child root "financing") | _, _ -> Ok Financing.legacy_policy in let financing = - if List.mem contract_version [ "11"; "10" ] then Some financing else None + if List.mem contract_version [ "12"; "11"; "10" ] then Some financing + else None in let* settlement = match (contract_version, shape.settlement) with - | "11", Some json -> + | ("12" | "11"), Some json -> let* policy = parse_settlement json |> at (child root "settlement") in Ok (Some policy) - | "11", None -> + | ("12" | "11"), None -> Error "missing settlement policy" |> at (child root "settlement") | _, _ -> Ok None in diff --git a/lib/scenario_shape.ml b/lib/scenario_shape.ml index c58e96c..e385c8d 100644 --- a/lib/scenario_shape.ml +++ b/lib/scenario_shape.ml @@ -70,26 +70,26 @@ let common ~root ~contract_version fields = let* run_id = field ~root fields "run_id" in let* base_currency = field ~root fields "base_currency" in let initial_field = - if List.mem contract_version [ "11"; "10"; "9"; "8"; "7"; "6" ] then + if List.mem contract_version [ "12"; "11"; "10"; "9"; "8"; "7"; "6" ] then "initial_portfolio" else "initial_cash" in let* initial_state = field ~root fields initial_field in let* instruments = field ~root fields "instruments" in let venue_calendars = - if List.mem contract_version [ "11"; "10"; "9"; "8"; "7"; "6"; "5" ] then - List.assoc_opt "venue_calendars" fields + if List.mem contract_version [ "12"; "11"; "10"; "9"; "8"; "7"; "6"; "5" ] + then List.assoc_opt "venue_calendars" fields else None in let* risk = field ~root fields "risk" in let* execution = field ~root fields "execution" in let financing = - if List.mem contract_version [ "11"; "10" ] then + if List.mem contract_version [ "12"; "11"; "10" ] then List.assoc_opt "financing" fields else None in let settlement = - if String.equal contract_version "11" then + if List.mem contract_version [ "12"; "11" ] then List.assoc_opt "settlement" fields else None in @@ -120,12 +120,12 @@ let batch json = match preliminary with `String value -> value | _ -> "" in let calendar_fields = - if List.mem contract_version [ "11"; "10"; "9"; "8"; "7"; "6"; "5" ] then - [ "venue_calendars" ] + if List.mem contract_version [ "12"; "11"; "10"; "9"; "8"; "7"; "6"; "5" ] + then [ "venue_calendars" ] else [] in let initial_field = - if List.mem contract_version [ "11"; "10"; "9"; "8"; "7"; "6" ] then + if List.mem contract_version [ "12"; "11"; "10"; "9"; "8"; "7"; "6" ] then "initial_portfolio" else "initial_cash" in @@ -146,9 +146,12 @@ let batch json = "slices"; ] @ calendar_fields - @ (if List.mem contract_version [ "11"; "10" ] then [ "financing" ] + @ (if List.mem contract_version [ "12"; "11"; "10" ] then + [ "financing" ] else []) - @ if String.equal contract_version "11" then [ "settlement" ] else []) + @ + if List.mem contract_version [ "12"; "11" ] then [ "settlement" ] + else []) json in let* contract_version_json = field ~root fields "contract_version" in @@ -160,12 +163,12 @@ let batch json = let stream_header ~contract_version json = let root = "$.payload" in let calendar_fields = - if List.mem contract_version [ "11"; "10"; "9"; "8"; "7"; "6"; "5" ] then - [ "venue_calendars" ] + if List.mem contract_version [ "12"; "11"; "10"; "9"; "8"; "7"; "6"; "5" ] + then [ "venue_calendars" ] else [] in let initial_field = - if List.mem contract_version [ "11"; "10"; "9"; "8"; "7"; "6" ] then + if List.mem contract_version [ "12"; "11"; "10"; "9"; "8"; "7"; "6" ] then "initial_portfolio" else "initial_cash" in @@ -183,9 +186,12 @@ let stream_header ~contract_version json = "max_internal_events"; ] @ calendar_fields - @ (if List.mem contract_version [ "11"; "10" ] then [ "financing" ] + @ (if List.mem contract_version [ "12"; "11"; "10" ] then + [ "financing" ] else []) - @ if String.equal contract_version "11" then [ "settlement" ] else []) + @ + if List.mem contract_version [ "12"; "11" ] then [ "settlement" ] + else []) json in common ~root ~contract_version fields diff --git a/lib/scenario_validation.ml b/lib/scenario_validation.ml index 9f4dc91..21b9668 100644 --- a/lib/scenario_validation.ml +++ b/lib/scenario_validation.ml @@ -48,7 +48,8 @@ let validate_venue_calendars ~root catalog venue_calendars = let header ~root ~contract_version ~base_currency ~initial_cash ~instruments ~venue_calendars ~max_internal_events = let* () = - if List.mem contract_version [ "11"; "10"; "9"; "8"; "7"; "6" ] then Ok () + if List.mem contract_version [ "12"; "11"; "10"; "9"; "8"; "7"; "6" ] then + Ok () else Account.create ~base_currency ~initial_cash |> Result.map (fun _ -> ()) @@ -66,7 +67,9 @@ let header ~root ~contract_version ~base_currency ~initial_cash ~instruments fail ~json_path:(child root "instruments") "instrument IDs must be unique" else let* () = - if List.mem contract_version [ "11"; "10"; "9"; "8"; "7"; "6"; "5" ] + if + List.mem contract_version + [ "12"; "11"; "10"; "9"; "8"; "7"; "6"; "5" ] then validate_venue_calendars ~root catalog venue_calendars else Ok () in @@ -84,7 +87,9 @@ let header ~root ~contract_version ~base_currency ~initial_cash ~instruments fail ~json_path: (child root - (if List.mem contract_version [ "11"; "10"; "9"; "8"; "7"; "6" ] + (if + List.mem contract_version + [ "12"; "11"; "10"; "9"; "8"; "7"; "6" ] then "initial_portfolio.cash" else "initial_cash")) "initial cash must contain every scenario currency exactly once" @@ -327,14 +332,33 @@ let validate_slices_at ~paths ~base_currency ~currencies ~instruments slices = List.for_all (fun action -> Id.Instrument.Set.mem action.Corporate_action.instrument_id - catalog) + catalog + && + match action.kind with + | Corporate_action.Distribution { destination_instrument_id; _ } + -> + Id.Instrument.Set.mem destination_instrument_id catalog + | Split _ | Cash_dividend _ -> true) market_slice.corporate_actions in + let lifecycle_valid = + List.for_all + (fun (event : Instrument_lifecycle.event) -> + Id.Instrument.Set.mem event.instrument_id catalog) + market_slice.lifecycle_events + in let duplicate_action = + let current_ids = + List.map + (fun action -> action.Corporate_action.id) + market_slice.corporate_actions + @ List.map + (fun (event : Instrument_lifecycle.event) -> event.id) + market_slice.lifecycle_events + in List.find_opt - (fun action -> - Id.Corporate_action.Set.mem action.Corporate_action.id action_ids) - market_slice.corporate_actions + (fun id -> Id.Corporate_action.Set.mem id action_ids) + current_ids in let bars_aligned = List.for_all @@ -378,6 +402,10 @@ let validate_slices_at ~paths ~base_currency ~currencies ~instruments slices = fail ~json_path:(child root "corporate_actions") "corporate action refers to an unknown instrument" + else if not lifecycle_valid then + fail + ~json_path:(child root "lifecycle_events") + "lifecycle event refers to an unknown instrument" else if Option.is_some duplicate_action then fail ~json_path:(child root "corporate_actions") @@ -415,6 +443,11 @@ let validate_slices_at ~paths ~base_currency ~currencies ~instruments slices = (fun ids action -> Id.Corporate_action.Set.add action.Corporate_action.id ids) action_ids market_slice.corporate_actions + |> fun ids -> + List.fold_left + (fun ids (event : Instrument_lifecycle.event) -> + Id.Corporate_action.Set.add event.id ids) + ids market_slice.lifecycle_events in validate (index + 1) (Some market_slice.slice_sequence) (Some market_slice.end_at) (Some market_slice.received_at) @@ -517,6 +550,17 @@ let stream_item ~root ~base_currency ~instruments ~risk ~previous_slice else Ok (Id.Corporate_action.Set.add action.id ids)) (Ok prior_action_ids) market_slice.corporate_actions in + let* action_ids = + List.fold_left + (fun result (event : Instrument_lifecycle.event) -> + let* ids = result in + if Id.Corporate_action.Set.mem event.id ids then + fail + ~json_path:(child current_slice_path "lifecycle_events") + "action and lifecycle IDs must be unique across the scenario stream" + else Ok (Id.Corporate_action.Set.add event.id ids)) + (Ok action_ids) market_slice.lifecycle_events + in let currencies = base_currency :: List.map diff --git a/lib/strategy_protocol.ml b/lib/strategy_protocol.ml index 1daaf2c..5f0790a 100644 --- a/lib/strategy_protocol.ml +++ b/lib/strategy_protocol.ml @@ -103,7 +103,7 @@ let group_kind_to_string = function let nullable render = Option.fold ~none:`Null ~some:render let modern_protocol protocol_version = - List.mem protocol_version [ "9"; "8"; "7"; "6"; "5" ] + List.mem protocol_version [ "10"; "9"; "8"; "7"; "6"; "5" ] let financing_to_yojson policy = `Assoc @@ -252,7 +252,7 @@ let execution_to_yojson ~protocol_version model execution = (Fee_schedule.components schedule)) ); ] in - if List.mem protocol_version [ "9"; "8"; "7" ] then + if List.mem protocol_version [ "10"; "9"; "8"; "7" ] then `Assoc [ ("model", string (Execution_model.name model)); @@ -291,6 +291,7 @@ let execution_to_yojson ~protocol_version model execution = let protocol_version initialization = match initialization.scenario_contract_version with + | "12" -> "10" | "11" -> "9" | "10" -> "8" | "9" -> "7" @@ -336,7 +337,7 @@ let initialize_message ~sequence:message_sequence initialization = ] in let fields = - if List.mem protocol_version [ "9"; "8"; "7"; "6" ] then + if List.mem protocol_version [ "10"; "9"; "8"; "7"; "6" ] then let initial_portfolio = Option.fold ~none:`Null ~some:Codec.initial_portfolio_to_yojson initialization.initial_portfolio @@ -349,14 +350,14 @@ let initialize_message ~sequence:message_sequence initialization = ( "venue_calendars", `List (List.map venue_calendar_to_yojson venue_calendars) ); ]; - (if List.mem protocol_version [ "9"; "8" ] then + (if List.mem protocol_version [ "10"; "9"; "8" ] then [ ( "financing", Option.fold ~none:`Null ~some:financing_to_yojson initialization.financing ); ] else []); - (if String.equal protocol_version "9" then + (if List.mem protocol_version [ "10"; "9" ] then [ ( "settlement", Option.fold ~none:`Null ~some:settlement_to_yojson @@ -390,14 +391,14 @@ let cash_attribution_to_yojson ~protocol_version ("fx_rate", price balance.fx_rate); ("base_value", money balance.base_value); ] - @ (if List.mem protocol_version [ "9"; "8" ] then + @ (if List.mem protocol_version [ "10"; "9"; "8" ] then [ ("interest", money balance.interest); ("base_interest", money balance.base_interest); ] else []) @ - if String.equal protocol_version "9" then + if List.mem protocol_version [ "10"; "9" ] then [ ("settled_amount", money balance.settled_amount); ("unsettled_amount", money balance.unsettled_amount); @@ -417,7 +418,7 @@ let marked_position_to_yojson ~protocol_version ("weight", Option.fold ~none:`Null ~some:weight position.weight); ] @ - if String.equal protocol_version "9" then + if List.mem protocol_version [ "10"; "9" ] then [ ("settled_quantity", quantity position.settled_quantity); ("unsettled_quantity", quantity position.unsettled_quantity); @@ -500,7 +501,7 @@ let context_to_yojson ~protocol_version context = ( "working_orders", `List (List.map - (if List.mem protocol_version [ "9"; "8"; "7"; "6" ] then + (if List.mem protocol_version [ "10"; "9"; "8"; "7"; "6" ] then Codec.order_to_yojson_v8 else Codec.order_to_yojson) working_orders) ); @@ -513,7 +514,9 @@ let event_to_yojson ~protocol_version = function [ ("type", string "market_slice_closed"); ( "market_slice", - if String.equal protocol_version "9" then + if String.equal protocol_version "10" then + Codec.market_slice_to_yojson_v12 market_slice + else if String.equal protocol_version "9" then Codec.market_slice_to_yojson_v11 market_slice else if String.equal protocol_version "8" then Codec.market_slice_to_yojson_v10 market_slice @@ -524,7 +527,7 @@ let event_to_yojson ~protocol_version = function [ ("type", string "fill_received"); ( "fill", - if List.mem protocol_version [ "9"; "8"; "7" ] then + if List.mem protocol_version [ "10"; "9"; "8"; "7" ] then Codec.fill_to_yojson_v9 fill else Codec.fill_to_yojson fill ); ] @@ -533,7 +536,7 @@ let event_to_yojson ~protocol_version = function [ ("type", string "order_updated"); ( "order", - if List.mem protocol_version [ "9"; "8"; "7"; "6" ] then + if List.mem protocol_version [ "10"; "9"; "8"; "7"; "6" ] then Codec.order_to_yojson_v8 order else Codec.order_to_yojson order ); ] @@ -621,7 +624,8 @@ let parse_intents_payload ~protocol_version json = let* intent = Scenario.intent_of_yojson ~contract_version: - (if String.equal protocol_version "9" then "11" + (if String.equal protocol_version "10" then "12" + else if String.equal protocol_version "9" then "11" else if String.equal protocol_version "8" then "10" else if String.equal protocol_version "7" then "9" else if String.equal protocol_version "6" then "8" diff --git a/mkdocs.yml b/mkdocs.yml index c11b345..c9145e4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -29,14 +29,14 @@ nav: - Diagnostics: - Current v1: contracts/diagnostic/v1/README.md - Scenario and journal: - - Current v11: contracts/v11/README.md + - Current v12: contracts/v12/README.md - Transitional v5: contracts/v5/README.md - Transitional v4: contracts/v4/README.md - Transitional v3: contracts/v3/README.md - Frozen v2: contracts/v2/README.md - Historical v1: contracts/v1/README.md - External strategy: - - Current v9: contracts/strategy/v9/README.md + - Current v10: contracts/strategy/v10/README.md - Historical v3: contracts/strategy/v3/README.md - Historical v2: contracts/strategy/v2/README.md - Historical v1: contracts/strategy/v1/README.md diff --git a/scripts/check-deterministic-journals b/scripts/check-deterministic-journals index a9cd805..a3d533b 100755 --- a/scripts/check-deterministic-journals +++ b/scripts/check-deterministic-journals @@ -82,3 +82,11 @@ compare_journal \ v11-fill-clipped \ contracts/v11/fixtures/fill-clipped.scenario.json \ contracts/v11/fixtures/fill-clipped.journal.jsonl +compare_journal \ + v12-demo \ + contracts/v12/fixtures/demo.scenario.json \ + contracts/v12/fixtures/demo.journal.jsonl +compare_journal \ + v12-fill-clipped \ + contracts/v12/fixtures/fill-clipped.scenario.json \ + contracts/v12/fixtures/fill-clipped.journal.jsonl diff --git a/scripts/check-documentation.py b/scripts/check-documentation.py index c134f22..bbe4a58 100644 --- a/scripts/check-documentation.py +++ b/scripts/check-documentation.py @@ -26,13 +26,13 @@ "docs/persistra.md", "SECURITY.md", "contracts/conformance/README.md", - "contracts/v11/README.md", + "contracts/v12/README.md", "contracts/v5/README.md", "contracts/v4/README.md", "contracts/v3/README.md", "contracts/v2/README.md", "contracts/v1/README.md", - "contracts/strategy/v9/README.md", + "contracts/strategy/v10/README.md", "contracts/strategy/v3/README.md", "contracts/strategy/v2/README.md", "contracts/strategy/v1/README.md", diff --git a/scripts/release_artifacts.py b/scripts/release_artifacts.py index 6b28226..3915dfe 100644 --- a/scripts/release_artifacts.py +++ b/scripts/release_artifacts.py @@ -376,8 +376,8 @@ def verify_release( ( "bin/trading-engine", "lib/trading_engine/opam", - "share/trading_engine/contracts/v11/scenario.schema.json", - "share/trading_engine/contracts/v11/fixtures/demo.scenario.json", + "share/trading_engine/contracts/v12/scenario.schema.json", + "share/trading_engine/contracts/v12/fixtures/demo.scenario.json", "doc/trading_engine/README.md", ), epoch, @@ -388,7 +388,7 @@ def verify_release( ( "trading_engine.opam", "contracts/v1/scenario.schema.json", - "contracts/v11/fixtures/demo.scenario.json", + "contracts/v12/fixtures/demo.scenario.json", "docs/architecture.md", ".github/workflows/release-candidate.yml", ), @@ -400,8 +400,8 @@ def verify_release( ( "contracts/conformance/manifest.json", "contracts/v1/scenario.schema.json", - "contracts/v11/fixtures/demo.scenario.json", - "contracts/strategy/v9/message.schema.json", + "contracts/v12/fixtures/demo.scenario.json", + "contracts/strategy/v10/message.schema.json", ), epoch, ) @@ -412,7 +412,7 @@ def verify_release( "index.html", "docs/architecture/index.html", "contracts/v1/index.html", - "contracts/v11/scenario.schema.json", + "contracts/v12/scenario.schema.json", "api/trading_engine/Trading_engine/index.html", ), epoch, diff --git a/test/cli.t b/test/cli.t index f0497fe..a77dbbd 100644 --- a/test/cli.t +++ b/test/cli.t @@ -2,7 +2,7 @@ 1.0.0 $ ../bin/main.exe --capabilities - {"engine_version":"1.0.0","scenario_contract_versions":["11","10","9","8","7","6","5","4","3"],"journal_contract_versions":["11","10","9","8","7","6","5","4","3"],"scenario_formats":["json","jsonl"],"journal_formats":["jsonl"],"execution_models":["completed_bar_v1"],"execution_model_contracts":[{"name":"completed_bar_v1","configuration_versions":["2","1"],"scenario_contract_versions":["11","10","9","8","7","6","5","4","3"],"required_fields":["version","participation_bps","fee_schedules"],"configuration_required_fields":{"2":["version","participation_bps","fee_schedules"],"1":["version","participation_bps","fixed_fee","fee_bps"]},"supported_order_types":["market","limit","stop","stop_limit"],"data_requirements":["completed_ohlcv_bars"],"limits":{"participation_bps":{"minimum":0,"maximum":10000},"fee_bps":{"minimum":0,"maximum":10000},"fixed_fee":{"minimum":"0","unit":"money"}}}],"strategy_protocol_versions":["9","8","7","6","5","4","3"],"resource_limits":{"version":"1","scenario_record_bytes":1048576,"strategy_message_bytes":1048576,"internal_events":100000,"catalog_instruments":4096,"intents_per_batch":4096,"artifact_record_bytes":2097152}} + {"engine_version":"1.0.0","scenario_contract_versions":["12","11","10","9","8","7","6","5","4","3"],"journal_contract_versions":["12","11","10","9","8","7","6","5","4","3"],"scenario_formats":["json","jsonl"],"journal_formats":["jsonl"],"execution_models":["completed_bar_v1"],"execution_model_contracts":[{"name":"completed_bar_v1","configuration_versions":["2","1"],"scenario_contract_versions":["12","11","10","9","8","7","6","5","4","3"],"required_fields":["version","participation_bps","fee_schedules"],"configuration_required_fields":{"2":["version","participation_bps","fee_schedules"],"1":["version","participation_bps","fixed_fee","fee_bps"]},"supported_order_types":["market","limit","stop","stop_limit"],"data_requirements":["completed_ohlcv_bars"],"limits":{"participation_bps":{"minimum":0,"maximum":10000},"fee_bps":{"minimum":0,"maximum":10000},"fixed_fee":{"minimum":"0","unit":"money"}}}],"strategy_protocol_versions":["10","9","8","7","6","5","4","3"],"resource_limits":{"version":"1","scenario_record_bytes":1048576,"strategy_message_bytes":1048576,"internal_events":100000,"catalog_instruments":4096,"intents_per_batch":4096,"artifact_record_bytes":2097152}} $ ../bin/main.exe --validate-only --input ../contracts/v8/fixtures/demo.scenario.json valid run=demo instruments=1 schedule=2 slices=4 scenario_sha256=85f7c99e0666159579c79256b3d0dc5f9c328e1275b79465fe1d4c93883e68f1 diff --git a/test/dune b/test/dune index 4662ab7..a0c266d 100644 --- a/test/dune +++ b/test/dune @@ -10,6 +10,7 @@ test_fee_schedules test_financing test_settlement + test_corporate_lifecycle test_reducer test_reducer_properties test_checkpoint4 @@ -61,6 +62,14 @@ ../contracts/v11/journal.schema.json ../contracts/v11/scenario-stream.schema.json ../contracts/v11/scenario.schema.json + ../contracts/v12/fixtures/demo.journal.jsonl + ../contracts/v12/fixtures/demo.scenario.json + ../contracts/v12/fixtures/demo.scenario.jsonl + ../contracts/v12/fixtures/fill-clipped.journal.jsonl + ../contracts/v12/fixtures/fill-clipped.scenario.json + ../contracts/v12/journal.schema.json + ../contracts/v12/scenario-stream.schema.json + ../contracts/v12/scenario.schema.json ../contracts/v6/fixtures/demo.scenario.json ../contracts/v6/fixtures/demo.scenario.jsonl ../contracts/v5/fixtures/demo.scenario.json @@ -75,6 +84,7 @@ ../contracts/strategy/v7/fixtures/external.strategy.jsonl ../contracts/strategy/v8/fixtures/external.strategy.jsonl ../contracts/strategy/v9/fixtures/external.strategy.jsonl + ../contracts/strategy/v10/fixtures/external.strategy.jsonl ../contracts/strategy/v4/fixtures/external.strategy.jsonl fake_strategy.py) (libraries @@ -93,6 +103,69 @@ (modules fuzz_protocol) (libraries trading_engine yojson unix)) +(rule + (alias runtest) + (deps + validate_schemas.py + ../contracts/v12/fixtures/demo.journal.jsonl + ../contracts/v12/fixtures/demo.scenario.json + ../contracts/v12/fixtures/demo.scenario.jsonl + ../contracts/v12/journal.schema.json + ../contracts/v12/scenario-stream.schema.json + ../contracts/v12/scenario.schema.json) + (action + (run + python3 + %{dep:validate_schemas.py} + %{dep:../contracts/v12/scenario.schema.json} + %{dep:../contracts/v12/scenario-stream.schema.json} + %{dep:../contracts/v12/journal.schema.json} + %{dep:../contracts/v12/fixtures/demo.scenario.json} + %{dep:../contracts/v12/fixtures/demo.scenario.jsonl} + %{dep:../contracts/v12/fixtures/demo.journal.jsonl}))) + +(rule + (alias runtest) + (deps + validate_schemas.py + ../contracts/v12/fixtures/fill-clipped.journal.jsonl + ../contracts/v12/fixtures/fill-clipped.scenario.json + ../contracts/v12/fixtures/demo.scenario.jsonl + ../contracts/v12/journal.schema.json + ../contracts/v12/scenario-stream.schema.json + ../contracts/v12/scenario.schema.json) + (action + (run + python3 + %{dep:validate_schemas.py} + %{dep:../contracts/v12/scenario.schema.json} + %{dep:../contracts/v12/scenario-stream.schema.json} + %{dep:../contracts/v12/journal.schema.json} + %{dep:../contracts/v12/fixtures/fill-clipped.scenario.json} + %{dep:../contracts/v12/fixtures/demo.scenario.jsonl} + %{dep:../contracts/v12/fixtures/fill-clipped.journal.jsonl}))) + +(rule + (alias runtest) + (deps + validate_strategy_schema.py + ../contracts/v12/scenario.schema.json + ../contracts/v12/journal.schema.json + ../contracts/diagnostic/v1/diagnostic.schema.json + ../contracts/strategy/v10/message.schema.json + ../contracts/strategy/v10/transcript.schema.json + ../contracts/strategy/v10/fixtures/external.strategy.jsonl) + (action + (run + python3 + %{dep:validate_strategy_schema.py} + %{dep:../contracts/v12/scenario.schema.json} + %{dep:../contracts/v12/journal.schema.json} + %{dep:../contracts/diagnostic/v1/diagnostic.schema.json} + %{dep:../contracts/strategy/v10/message.schema.json} + %{dep:../contracts/strategy/v10/transcript.schema.json} + %{dep:../contracts/strategy/v10/fixtures/external.strategy.jsonl}))) + (rule (alias runtest) (deps diff --git a/test/test_corporate_lifecycle.ml b/test/test_corporate_lifecycle.ml new file mode 100644 index 0000000..afd7d58 --- /dev/null +++ b/test/test_corporate_lifecycle.ml @@ -0,0 +1,507 @@ +open Test_support +module T = Trading_engine +module Runner = T.Engine.Make (T.Scripted_strategy) + +let action_id value = T.Id.Corporate_action.of_string_exn value + +let distribution_allocates_basis_and_fractional_cash () = + let source = instrument ~id:"source" ~symbol:"SRC" () in + let child = instrument ~id:"child" ~symbol:"CHD" () in + let account = test_account ~initial_cash:[ ("USD", money "1000") ] () in + let order = + request ~instrument:source.id ~quantity_value:"3" () |> accepted_order + in + let account = + T.Account.apply_fill account + (fill ~price_value:"100" ~quantity_value:"3" order) + |> ok + in + let fractional_policy = + T.Corporate_action.Cash_in_lieu { price = price "20"; currency = "USD" } + in + let account, result = + T.Account.apply_distribution account ~source_instrument_id:source.id + ~destination_instrument_id:child.id ~destination_lot_size:child.lot_size + ~numerator:1L ~denominator:2L ~basis_allocation_bps:2000 + ~fractional_policy + |> ok + in + Alcotest.check quantity_testable "delivered child units" (quantity "1") + result.destination_quantity; + Alcotest.check quantity_testable "fractional child units" (quantity "0.5") + result.fractional_quantity; + Alcotest.check money_testable "allocated source basis" (money "60") + result.allocated_basis; + Alcotest.check money_testable "fractional basis" (money "20") + result.fractional_basis; + Alcotest.check money_testable "cash in lieu" (money "10") result.cash_in_lieu; + Alcotest.check money_testable "source basis retained" (money "240") + (T.Account.position account source.id).cost_basis; + Alcotest.check money_testable "child basis delivered" (money "40") + (T.Account.position account child.id).cost_basis; + Alcotest.check money_testable "cash credited in declared currency" + (money "710") (account_cash account) + +let fractional_policy_is_explicit () = + let source = instrument ~id:"source" ~symbol:"SRC" () in + let child = instrument ~id:"child" ~symbol:"CHD" () in + let account = test_account () in + let order = + request ~instrument:source.id ~quantity_value:"3" () |> accepted_order + in + let account = + T.Account.apply_fill account + (fill ~price_value:"100" ~quantity_value:"3" order) + |> ok + in + Alcotest.(check bool) + "fractional entitlement rejected" true + (Result.is_error + (T.Account.apply_distribution account ~source_instrument_id:source.id + ~destination_instrument_id:child.id + ~destination_lot_size:child.lot_size ~numerator:1L ~denominator:2L + ~basis_allocation_bps:2000 + ~fractional_policy:T.Corporate_action.Reject_fractional)) + +let lifecycle_preserves_identity_and_terminal_state () = + let configured = instrument ~id:"stable-id" ~symbol:"OLD" () in + let state = T.Instrument_lifecycle.create [ configured ] |> ok in + let event name kind = + T.Instrument_lifecycle.create_event ~id:(action_id name) + ~instrument_id:configured.id ~kind + |> ok + in + let state = + T.Instrument_lifecycle.apply state + (event "rename" + (T.Instrument_lifecycle.Identifier_change + { + symbol = "NEW"; + provider = "sip"; + provider_instrument_id = "NEW.X"; + })) + |> ok + in + let listing = + T.Instrument_lifecycle.listing state configured.id |> Option.get + in + Alcotest.(check string) + "stable identity" "stable-id" + (T.Id.Instrument.to_string listing.instrument_id); + Alcotest.(check string) "new symbol" "NEW" listing.symbol; + Alcotest.(check (list (pair string string))) + "provider provenance" + [ ("sip", "NEW.X") ] + listing.provider_mappings; + let state = + T.Instrument_lifecycle.apply state + (event "halt" (T.Instrument_lifecycle.Halt { reason = "volatility" })) + |> ok + in + Alcotest.(check bool) + "halt is not tradable" false + (T.Instrument_lifecycle.is_tradable state configured.id); + let state = + T.Instrument_lifecycle.apply state + (event "resume" T.Instrument_lifecycle.Resume) + |> ok + in + Alcotest.(check bool) + "resume is tradable" true + (T.Instrument_lifecycle.is_tradable state configured.id); + let state = + T.Instrument_lifecycle.apply state + (event "expire" + (T.Instrument_lifecycle.Expiration + { terminal_policy = T.Instrument_lifecycle.Hold })) + |> ok + in + Alcotest.(check bool) + "expiration is terminal" false + (T.Instrument_lifecycle.is_tradable state configured.id); + Alcotest.(check bool) + "terminal event rejects resume" true + (Result.is_error + (T.Instrument_lifecycle.apply state + (event "late-resume" T.Instrument_lifecycle.Resume))) + +let constructors_reject_ambiguous_policies () = + let id = action_id "distribution" in + let source = instrument_id "source" in + let child = instrument_id "child" in + Alcotest.(check bool) + "stock destination must be source" true + (Result.is_error + (T.Corporate_action.distribution ~id ~instrument_id:source + ~distribution_type:T.Corporate_action.Stock_dividend + ~destination_instrument_id:child ~numerator:1L ~denominator:10L + ~basis_allocation_bps:0 + ~fractional_policy:T.Corporate_action.Reject_fractional)); + Alcotest.(check bool) + "basis allocation bounded" true + (Result.is_error + (T.Corporate_action.distribution ~id ~instrument_id:source + ~distribution_type:T.Corporate_action.Spin_off + ~destination_instrument_id:child ~numerator:1L ~denominator:10L + ~basis_allocation_bps:10_001 + ~fractional_policy:T.Corporate_action.Reject_fractional)) + +let policy_and_transition_boundaries () = + let id = action_id "boundary-event" in + let source = instrument_id "source" in + let child = instrument_id "child" in + let distribution ?(distribution_type = T.Corporate_action.Spin_off) + ?(destination = child) ?(numerator = 1L) ?(denominator = 2L) + ?(basis = 1000) + ?(fractional_policy = T.Corporate_action.Reject_fractional) () = + T.Corporate_action.distribution ~id ~instrument_id:source ~distribution_type + ~destination_instrument_id:destination ~numerator ~denominator + ~basis_allocation_bps:basis ~fractional_policy + in + List.iter + (fun (name, result) -> + Alcotest.(check bool) name true (Result.is_error result)) + [ + ("zero numerator", distribution ~numerator:0L ()); + ("zero denominator", distribution ~denominator:0L ()); + ("negative basis", distribution ~basis:(-1) ()); + ( "stock basis must be zero", + distribution ~distribution_type:T.Corporate_action.Stock_dividend + ~destination:source () ); + ( "stock ratio overflow", + distribution ~distribution_type:T.Corporate_action.Stock_dividend + ~destination:source ~basis:0 ~numerator:Int64.max_int () ); + ("spin-off destination differs", distribution ~destination:source ()); + ( "cash currency label", + distribution + ~fractional_policy: + (T.Corporate_action.Cash_in_lieu + { price = price "1"; currency = "bad currency" }) + () ); + ]; + Alcotest.(check (list string)) + "distribution labels" + [ "stock_dividend"; "rights"; "spin_off" ] + (List.map T.Corporate_action.distribution_type_to_string + [ + T.Corporate_action.Stock_dividend; + T.Corporate_action.Rights; + T.Corporate_action.Spin_off; + ]); + Alcotest.(check string) + "distribution formatting" "boundary-event spin_off 1:2 source" + (Format.asprintf "%a" T.Corporate_action.pp (distribution () |> ok)); + let configured = instrument ~id:"stable" ~symbol:"OLD" () in + Alcotest.(check bool) + "duplicate lifecycle catalog" true + (Result.is_error (T.Instrument_lifecycle.create [ configured; configured ])); + let state = T.Instrument_lifecycle.create [ configured ] |> ok in + Alcotest.(check bool) + "unknown instrument is not tradable" false + (T.Instrument_lifecycle.is_tradable state (instrument_id "unknown")); + let create ?(instrument_id = configured.id) name kind = + T.Instrument_lifecycle.create_event ~id:(action_id name) ~instrument_id + ~kind + in + List.iter + (fun (name, result) -> + Alcotest.(check bool) name true (Result.is_error result)) + [ + ( "invalid halt reason", + create "bad-halt" (T.Instrument_lifecycle.Halt { reason = "" }) ); + ( "invalid delisting reason", + create "bad-delist" + (T.Instrument_lifecycle.Delisting + { + terminal_policy = T.Instrument_lifecycle.Hold; + reason = "bad reason"; + }) ); + ( "invalid identifier mapping", + create "bad-id" + (T.Instrument_lifecycle.Identifier_change + { symbol = ""; provider = "sip"; provider_instrument_id = "x" }) ); + ( "invalid terminal currency", + create "bad-terminal" + (T.Instrument_lifecycle.Expiration + { + terminal_policy = + T.Instrument_lifecycle.Cash_out + { price = price "1"; currency = "" }; + }) ); + ]; + let unknown = + create ~instrument_id:(instrument_id "unknown") "unknown-event" + (T.Instrument_lifecycle.Halt { reason = "halt" }) + |> ok + in + Alcotest.(check bool) + "unknown lifecycle instrument" true + (Result.is_error (T.Instrument_lifecycle.apply state unknown)); + let resume = create "early-resume" T.Instrument_lifecycle.Resume |> ok in + Alcotest.(check bool) + "tradable cannot resume" true + (Result.is_error (T.Instrument_lifecycle.apply state resume)); + let halt = + create "first-halt" (T.Instrument_lifecycle.Halt { reason = "halt" }) |> ok + in + let halted = T.Instrument_lifecycle.apply state halt |> ok in + let second_halt = + create "second-halt" (T.Instrument_lifecycle.Halt { reason = "halt" }) |> ok + in + Alcotest.(check bool) + "halted cannot halt" true + (Result.is_error (T.Instrument_lifecycle.apply halted second_halt)); + Alcotest.(check (list string)) + "status labels" + [ "tradable"; "halted"; "expired"; "delisted" ] + (List.map T.Instrument_lifecycle.status_to_string + [ + T.Instrument_lifecycle.Tradable; + T.Instrument_lifecycle.Halted; + T.Instrument_lifecycle.Expired; + T.Instrument_lifecycle.Delisted; + ]); + let kinds = + [ + T.Instrument_lifecycle.Halt { reason = "halt" }; + T.Instrument_lifecycle.Resume; + T.Instrument_lifecycle.Identifier_change + { symbol = "NEW"; provider = "sip"; provider_instrument_id = "NEW.X" }; + T.Instrument_lifecycle.Expiration + { terminal_policy = T.Instrument_lifecycle.Hold }; + T.Instrument_lifecycle.Delisting + { + terminal_policy = T.Instrument_lifecycle.Hold; + reason = "acquisition"; + }; + ] + in + Alcotest.(check (list string)) + "kind labels" + [ "halt"; "resume"; "identifier_change"; "expiration"; "delisting" ] + (List.map T.Instrument_lifecycle.kind_to_string kinds); + let renamed = + T.Instrument_lifecycle.apply state + (create "provider-b" + (T.Instrument_lifecycle.Identifier_change + { symbol = "NEW"; provider = "b"; provider_instrument_id = "2" }) + |> ok) + |> ok + in + let renamed = + T.Instrument_lifecycle.apply renamed + (create "provider-a" + (T.Instrument_lifecycle.Identifier_change + { symbol = "NEW"; provider = "a"; provider_instrument_id = "1" }) + |> ok) + |> ok + in + let delisting = + create "valid-delisting" + (T.Instrument_lifecycle.Delisting + { + terminal_policy = T.Instrument_lifecycle.Hold; + reason = "acquisition"; + }) + |> ok + in + let delisted = T.Instrument_lifecycle.apply renamed delisting |> ok in + Alcotest.(check bool) + "delisted is terminal" true + (Result.is_error (T.Instrument_lifecycle.apply delisted resume)); + let expiration = + create "halted-expiration" + (T.Instrument_lifecycle.Expiration + { terminal_policy = T.Instrument_lifecycle.Hold }) + |> ok + in + ignore (T.Instrument_lifecycle.apply halted expiration |> ok) + +let lifecycle_slice ?(corporate_actions = []) ?(lifecycle_events = []) sequence + = + let date = Int64.to_int sequence + 1 in + T.Market_slice.create_v12 ~slice_sequence:sequence + ~start_at:(timestamp (Printf.sprintf "2026-03-%02dT14:30:00Z" date)) + ~end_at:(timestamp (Printf.sprintf "2026-03-%02dT21:00:00Z" date)) + ~available_at:(timestamp (Printf.sprintf "2026-03-%02dT21:00:01Z" date)) + ~received_at:(timestamp (Printf.sprintf "2026-03-%02dT21:00:02Z" date)) + ~bars:[ bar sequence ] + ~fx_rates:[ fx_mark () ] + ~corporate_actions ~borrow_observations:[] ~cash_rate_observations:[] + ~settlement_failures:[] ~lifecycle_events + |> ok + +let lifecycle_runner schedule run = + let config = engine_config ~contract_version:"12" () in + let strategy_state = T.Scripted_strategy.create schedule |> ok in + Runner.create ~run_id:(run_id run) ~scenario_sha256 ~config + ~initial_cash:[ ("USD", money "10000") ] + ~strategy_state + |> ok + +let halt_cancels_orders_and_rejects_new_exposure () = + let working = + request ~kind:(T.Order.Limit (price "50")) ~quantity_value:"2" () + in + let schedule = + [ + (1L, [ T.Strategy.Submit_order working ]); + (2L, [ T.Strategy.Submit_order working ]); + ] + in + let state, _ = + Runner.process_slice + (lifecycle_runner schedule "halt-run") + (lifecycle_slice 1L) + |> ok + in + let halt = + T.Instrument_lifecycle.create_event ~id:(action_id "halt-event") + ~instrument_id:(instrument_id "test-equity") + ~kind:(T.Instrument_lifecycle.Halt { reason = "regulatory" }) + |> ok + in + let state, events = + Runner.process_slice state (lifecycle_slice ~lifecycle_events:[ halt ] 2L) + |> ok + in + Alcotest.(check bool) + "halt audit" true + (List.exists + (fun (audit : T.Audit.t) -> + match audit.event with + | T.Audit.Lifecycle_applied _ -> true + | _ -> false) + events); + Alcotest.(check bool) + "working order cancelled" true + (List.exists + (fun (audit : T.Audit.t) -> + match audit.event with + | T.Audit.Order_cancelled { reason = T.Audit.Instrument_halt; _ } -> + true + | _ -> false) + events); + Alcotest.(check bool) + "same-slice new order rejected" true + (List.exists + (fun (audit : T.Audit.t) -> + match audit.event with + | T.Audit.Order_rejected order -> + order.status = T.Order.Rejected "instrument is not tradable" + | _ -> false) + events); + Alcotest.(check int) + "no active orders" 0 + (List.length (T.Oms.active_orders (Runner.oms state))) + +let terminal_cash_out_is_auditable () = + let target = + T.Strategy.Target_quantities + [ + { instrument_id = instrument_id "test-equity"; quantity = quantity "3" }; + ] + in + let state = lifecycle_runner [ (1L, [ target ]) ] "terminal-run" in + let state, _ = Runner.process_slice state (lifecycle_slice 1L) |> ok in + let state, _ = Runner.process_slice state (lifecycle_slice 2L) |> ok in + let expiration = + T.Instrument_lifecycle.create_event + ~id:(action_id "expiration-event") + ~instrument_id:(instrument_id "test-equity") + ~kind: + (T.Instrument_lifecycle.Expiration + { + terminal_policy = + T.Instrument_lifecycle.Cash_out + { price = price "90"; currency = "USD" }; + }) + |> ok + in + let state, events = + Runner.process_slice state + (lifecycle_slice ~lifecycle_events:[ expiration ] 3L) + |> ok + in + Alcotest.check quantity_testable "terminal position cleared" (quantity "0") + (T.Account.position_quantity (Runner.account state) + (instrument_id "test-equity")); + Alcotest.(check bool) + "terminal attribution" true + (List.exists + (fun (audit : T.Audit.t) -> + match audit.event with + | T.Audit.Lifecycle_applied + { liquidated_quantity; cash_amount; listing; _ } -> + T.Scalar.Quantity.equal liquidated_quantity (quantity "3") + && T.Scalar.Money.equal cash_amount (money "270") + && listing.status = T.Instrument_lifecycle.Expired + | _ -> false) + events) + +let stock_dividend_adjusts_account_and_target () = + let target = + T.Strategy.Target_quantities + [ + { instrument_id = instrument_id "test-equity"; quantity = quantity "3" }; + ] + in + let state = lifecycle_runner [ (1L, [ target ]) ] "stock-dividend-run" in + let state, _ = Runner.process_slice state (lifecycle_slice 1L) |> ok in + let state, _ = Runner.process_slice state (lifecycle_slice 2L) |> ok in + let action = + T.Corporate_action.distribution + ~id:(action_id "stock-dividend") + ~instrument_id:(instrument_id "test-equity") + ~distribution_type:T.Corporate_action.Stock_dividend + ~destination_instrument_id:(instrument_id "test-equity") + ~numerator:1L ~denominator:2L ~basis_allocation_bps:0 + ~fractional_policy: + (T.Corporate_action.Cash_in_lieu + { price = price "20"; currency = "USD" }) + |> ok + in + let state, events = + Runner.process_slice state + (lifecycle_slice ~corporate_actions:[ action ] 3L) + |> ok + in + Alcotest.check quantity_testable "lot-aligned stock entitlement" + (quantity "4") + (T.Account.position_quantity (Runner.account state) + (instrument_id "test-equity")); + Alcotest.(check bool) + "distribution attribution" true + (List.exists + (fun (audit : T.Audit.t) -> + match audit.event with + | T.Audit.Distribution_applied { result; _ } -> + T.Scalar.Quantity.equal result.destination_quantity (quantity "1") + && T.Scalar.Quantity.equal result.fractional_quantity + (quantity "0.5") + && T.Scalar.Money.equal result.cash_in_lieu (money "10") + | _ -> false) + events); + Alcotest.(check bool) + "fraction does not create a target order" true + (T.Oms.active_orders (Runner.oms state) = []) + +let tests = + [ + Alcotest.test_case "distribution basis and fractional cash" `Quick + distribution_allocates_basis_and_fractional_cash; + Alcotest.test_case "fractional policy is explicit" `Quick + fractional_policy_is_explicit; + Alcotest.test_case "lifecycle identity and terminal state" `Quick + lifecycle_preserves_identity_and_terminal_state; + Alcotest.test_case "constructors reject ambiguous policies" `Quick + constructors_reject_ambiguous_policies; + Alcotest.test_case "policy and transition boundaries" `Quick + policy_and_transition_boundaries; + Alcotest.test_case "halt cancels and rejects exposure" `Quick + halt_cancels_orders_and_rejects_new_exposure; + Alcotest.test_case "terminal cash-out is auditable" `Quick + terminal_cash_out_is_auditable; + Alcotest.test_case "stock dividend adjusts account and target" `Quick + stock_dividend_adjusts_account_and_target; + ] diff --git a/test/test_diagnostic.ml b/test/test_diagnostic.ml index 1024b5d..4e7a482 100644 --- a/test/test_diagnostic.ml +++ b/test/test_diagnostic.ml @@ -118,7 +118,7 @@ let capabilities_describe_execution_contracts () = (strings "configuration_versions"); Alcotest.(check (list string)) "scenario contracts" - [ "11"; "10"; "9"; "8"; "7"; "6"; "5"; "4"; "3" ] + [ "12"; "11"; "10"; "9"; "8"; "7"; "6"; "5"; "4"; "3" ] (strings "scenario_contract_versions"); Alcotest.(check (list string)) "required fields" diff --git a/test/test_engine.ml b/test/test_engine.ml index 2227ae0..fd1bd50 100644 --- a/test/test_engine.ml +++ b/test/test_engine.ml @@ -9,6 +9,7 @@ let () = ("fee-schedules", Test_fee_schedules.tests); ("financing", Test_financing.tests); ("settlement", Test_settlement.tests); + ("corporate-lifecycle", Test_corporate_lifecycle.tests); ("reducer", Test_reducer.tests); ("reducer-properties", Test_reducer_properties.tests); ("checkpoint4", Test_checkpoint4.tests); diff --git a/test/test_scenario.ml b/test/test_scenario.ml index 4fb37d1..c3c91ad 100644 --- a/test/test_scenario.ml +++ b/test/test_scenario.ml @@ -2,12 +2,12 @@ open Test_support module T = Trading_engine let demo_document () = - In_channel.with_open_bin "../contracts/v11/fixtures/demo.scenario.json" + In_channel.with_open_bin "../contracts/v12/fixtures/demo.scenario.json" In_channel.input_all let demo () = T.Scenario.of_string (demo_document ()) |> ok let demo_hash () = T.Sha256.digest_string (demo_document ()) -let stream_path = "../contracts/v11/fixtures/demo.scenario.jsonl" +let stream_path = "../contracts/v12/fixtures/demo.scenario.jsonl" let stream_document () = In_channel.with_open_bin stream_path In_channel.input_all @@ -75,7 +75,7 @@ let write_large_stream path slice_count = ~effective_at:start_at ~credit_rate_bps:0 ~debit_rate_bps:0 |> ok in - T.Market_slice.create_v11 ~slice_sequence:(Int64.of_int index) + T.Market_slice.create_v12 ~slice_sequence:(Int64.of_int index) ~start_at ~end_at:(add_seconds base (offset + 1)) ~available_at:(add_seconds base (offset + 2)) @@ -89,12 +89,13 @@ let write_large_stream path slice_count = ~fx_rates:[ fx_mark () ] ~corporate_actions:[] ~borrow_observations:[ borrow_observation ] ~cash_rate_observations:[ cash_rate ] ~settlement_failures:[] + ~lifecycle_events:[] |> ok in let payload = `Assoc [ - ("market_slice", T.Codec.market_slice_to_yojson_v11 market_slice); + ("market_slice", T.Codec.market_slice_to_yojson_v12 market_slice); ("intents", `List []); ] in @@ -139,9 +140,9 @@ let schema_artifacts_parse () = (List.mem_assoc "$defs" fields) | _ -> Alcotest.fail (path ^ " must contain a JSON object") in - check_schema "../contracts/v11/scenario.schema.json"; - check_schema "../contracts/v11/scenario-stream.schema.json"; - check_schema "../contracts/v11/journal.schema.json" + check_schema "../contracts/v12/scenario.schema.json"; + check_schema "../contracts/v12/scenario-stream.schema.json"; + check_schema "../contracts/v12/journal.schema.json" let timestamp_precision_is_bounded () = List.iter @@ -178,6 +179,233 @@ let map_root change = | `Assoc fields -> `Assoc (change fields) | _ -> Alcotest.fail "demo must be an object" +let replace_assoc name value fields = + (name, value) :: List.remove_assoc name fields + +let v12_distributions_and_lifecycle_parse () = + let source = instrument_id "demo-equity-acme" in + let child = instrument_id "demo-equity-child" in + let action name distribution_type destination fractional_policy = + T.Corporate_action.distribution + ~id:(T.Id.Corporate_action.of_string_exn name) + ~instrument_id:source ~distribution_type + ~destination_instrument_id:destination ~numerator:1L ~denominator:2L + ~basis_allocation_bps: + (if distribution_type = T.Corporate_action.Stock_dividend then 0 + else 2500) + ~fractional_policy + |> ok + in + let event name kind = + T.Instrument_lifecycle.create_event + ~id:(T.Id.Corporate_action.of_string_exn name) + ~instrument_id:source ~kind + |> ok + in + let market_slice = + T.Market_slice.create_v12 ~slice_sequence:1L + ~start_at:(timestamp "2026-01-02T14:30:00Z") + ~end_at:(timestamp "2026-01-02T20:55:00Z") + ~available_at:(timestamp "2026-01-02T21:00:00Z") + ~received_at:(timestamp "2026-01-02T21:00:01Z") + ~bars:[ bar ~instrument:source 1L; bar ~instrument:child 1L ] + ~fx_rates:[ fx_mark () ] + ~corporate_actions: + [ + action "stock-action" T.Corporate_action.Stock_dividend source + T.Corporate_action.Reject_fractional; + action "rights-action" T.Corporate_action.Rights child + (T.Corporate_action.Cash_in_lieu + { price = price "12.5"; currency = "USD" }); + action "spinoff-action" T.Corporate_action.Spin_off child + T.Corporate_action.Reject_fractional; + ] + ~borrow_observations:[] ~cash_rate_observations:[] ~settlement_failures:[] + ~lifecycle_events: + [ + event "rename-event" + (T.Instrument_lifecycle.Identifier_change + { + symbol = "ACME2"; + provider = "sip"; + provider_instrument_id = "ACME.X"; + }); + event "halt-event" + (T.Instrument_lifecycle.Halt { reason = "regulatory" }); + event "resume-event" T.Instrument_lifecycle.Resume; + event "expiration-event" + (T.Instrument_lifecycle.Expiration + { terminal_policy = T.Instrument_lifecycle.Hold }); + event "delisting-event" + (T.Instrument_lifecycle.Delisting + { + terminal_policy = + T.Instrument_lifecycle.Cash_out + { price = price "9"; currency = "USD" }; + reason = "acquisition"; + }); + ] + |> ok + in + let document = + map_root (fun fields -> + let instruments = + match List.assoc "instruments" fields with + | `List (`Assoc configured :: rest) -> + let child_instrument = + configured + |> replace_assoc "instrument_id" (`String "demo-equity-child") + |> replace_assoc "symbol" (`String "CHILD") + in + `List (`Assoc configured :: `Assoc child_instrument :: rest) + | _ -> Alcotest.fail "demo instruments must be a list" + in + let risk = + match List.assoc "risk" fields with + | `Assoc risk_fields -> + let policies = + match List.assoc "instrument_policies" risk_fields with + | `List (`Assoc configured :: rest) -> + let child_policy = + replace_assoc "instrument_id" + (`String "demo-equity-child") configured + in + `List (`Assoc configured :: `Assoc child_policy :: rest) + | _ -> Alcotest.fail "demo risk policies must be a list" + in + `Assoc (replace_assoc "instrument_policies" policies risk_fields) + | _ -> Alcotest.fail "demo risk must be an object" + in + let venue_calendars = + match List.assoc "venue_calendars" fields with + | `List [ `Assoc calendar ] -> + `List + [ + `Assoc + (replace_assoc "instrument_ids" + (`List + [ + `String "demo-equity-acme"; + `String "demo-equity-child"; + ]) + calendar); + ] + | _ -> Alcotest.fail "demo venue calendars must be a singleton" + in + let execution = + match List.assoc "execution" fields with + | `Assoc execution_fields -> ( + match List.assoc "configuration" execution_fields with + | `Assoc configuration -> + let schedules = + match List.assoc "fee_schedules" configuration with + | `List (`Assoc configured :: rest) -> + let child_schedule = + configured + |> replace_assoc "schedule_id" + (`String "demo-child-fees-v1") + |> replace_assoc "instrument_id" + (`String "demo-equity-child") + in + `List + (`Assoc configured :: `Assoc child_schedule :: rest) + | _ -> Alcotest.fail "demo fee schedules must be a list" + in + `Assoc + (replace_assoc "configuration" + (`Assoc + (replace_assoc "fee_schedules" schedules configuration)) + execution_fields) + | _ -> + Alcotest.fail "demo execution configuration must be an object" + ) + | _ -> Alcotest.fail "demo execution must be an object" + in + let slices = + match List.assoc "slices" fields with + | `List (_ :: rest) -> + let add_child_bar = function + | `Assoc slice_fields -> ( + match List.assoc "bars" slice_fields with + | `List (`Assoc configured :: bars) -> + let child_bar = + replace_assoc "instrument_id" + (`String "demo-equity-child") configured + in + `Assoc + (replace_assoc "bars" + (`List + (`Assoc configured :: `Assoc child_bar :: bars)) + slice_fields) + | _ -> Alcotest.fail "demo slice bars must be nonempty") + | _ -> Alcotest.fail "demo slice must be an object" + in + `List + (T.Codec.market_slice_to_yojson_v12 market_slice + :: List.map add_child_bar rest) + | _ -> Alcotest.fail "demo slices must be nonempty" + in + let schedule = + let add_child_target = function + | `Assoc intent_fields as intent -> ( + match List.assoc_opt "targets" intent_fields with + | Some (`List (`Assoc configured :: targets)) -> + let child_target = + configured + |> replace_assoc "instrument_id" + (`String "demo-equity-child") + |> fun fields -> + if List.mem_assoc "weight" fields then + replace_assoc "weight" (`String "0") fields + else replace_assoc "quantity" (`String "0") fields + in + `Assoc + (replace_assoc "targets" + (`List + (`Assoc configured :: `Assoc child_target :: targets)) + intent_fields) + | _ -> intent) + | json -> json + in + match List.assoc "schedule" fields with + | `List entries -> + `List + (List.map + (function + | `Assoc entry_fields -> ( + match List.assoc "intents" entry_fields with + | `List intents -> + `Assoc + (replace_assoc "intents" + (`List (List.map add_child_target intents)) + entry_fields) + | _ -> Alcotest.fail "schedule intents must be a list") + | _ -> Alcotest.fail "schedule entry must be an object") + entries) + | _ -> Alcotest.fail "demo schedule must be a list" + in + fields + |> replace_assoc "instruments" instruments + |> replace_assoc "risk" risk + |> replace_assoc "venue_calendars" venue_calendars + |> replace_assoc "execution" execution + |> replace_assoc "slices" slices + |> replace_assoc "schedule" schedule) + |> Yojson.Safe.to_string + in + let parsed = + match T.Scenario.of_string document with + | Ok value -> value + | Error diagnostic -> Alcotest.fail (T.Diagnostic.to_human diagnostic) + in + let first = List.hd parsed.slices in + Alcotest.(check int) + "all distribution variants" 3 + (List.length first.corporate_actions); + Alcotest.(check int) + "all lifecycle variants" 5 + (List.length first.lifecycle_events) + let unknown_fields_are_rejected () = let changed = map_root (fun fields -> ("unexpected", `Bool true) :: fields) in Alcotest.(check bool) @@ -203,8 +431,8 @@ let contract_version_is_required_and_supported () = let unsupported_diagnostic = T.Scenario.of_yojson unsupported |> error in Alcotest.(check string) "unsupported version diagnosed" - "unsupported scenario contract_version \"2\" (expected one of 11, 10, 9, \ - 8, 7, 6, 5, 4, 3)" + "unsupported scenario contract_version \"2\" (expected one of 12, 11, 10, \ + 9, 8, 7, 6, 5, 4, 3)" (T.Diagnostic.to_human unsupported_diagnostic); Alcotest.(check string) "unsupported version code" "scenario.unsupported_contract" @@ -354,7 +582,7 @@ let dense_schedule_document slice_count = ~effective_at:start_at ~credit_rate_bps:100 ~debit_rate_bps:200 |> ok in - T.Market_slice.create_v11 ~slice_sequence:(Int64.of_int index) ~start_at + T.Market_slice.create_v12 ~slice_sequence:(Int64.of_int index) ~start_at ~end_at:(add_seconds base (time_offset + 1)) ~available_at:(add_seconds base (time_offset + 2)) ~received_at:(add_seconds base (time_offset + 3)) @@ -367,8 +595,8 @@ let dense_schedule_document slice_count = ~fx_rates:[ fx_mark () ] ~corporate_actions:[] ~borrow_observations:[ borrow_observation ] ~cash_rate_observations:[ cash_rate_observation ] - ~settlement_failures:[] - |> ok |> T.Codec.market_slice_to_yojson_v11) + ~settlement_failures:[] ~lifecycle_events:[] + |> ok |> T.Codec.market_slice_to_yojson_v12) in let schedule = List.init slice_count (fun offset -> @@ -900,7 +1128,7 @@ let replay_matches_golden_file () = |> fun value -> value ^ "\n" in let expected = - In_channel.with_open_bin "../contracts/v11/fixtures/demo.journal.jsonl" + In_channel.with_open_bin "../contracts/v12/fixtures/demo.journal.jsonl" In_channel.input_all in Alcotest.(check string) "stable audit contract" expected actual @@ -928,7 +1156,7 @@ let v3_replay_matches_frozen_golden_file () = let fill_clipping_fixture_reconciles () = let document = In_channel.with_open_bin - "../contracts/v11/fixtures/fill-clipped.scenario.json" + "../contracts/v12/fixtures/fill-clipped.scenario.json" In_channel.input_all in let scenario = T.Scenario.of_string document |> ok in @@ -942,7 +1170,7 @@ let fill_clipping_fixture_reconciles () = in let expected = In_channel.with_open_bin - "../contracts/v11/fixtures/fill-clipped.journal.jsonl" + "../contracts/v12/fixtures/fill-clipped.journal.jsonl" In_channel.input_all in Alcotest.(check string) "fill clipping audit reconciliation" expected actual @@ -1245,6 +1473,8 @@ let large_stream_replay_does_not_retain_audit_history () = let tests = [ Alcotest.test_case "demo contract parses" `Quick demo_contract_parses; + Alcotest.test_case "v12 distributions and lifecycle parse" `Quick + v12_distributions_and_lifecycle_parse; Alcotest.test_case "schema artifacts parse" `Quick schema_artifacts_parse; Alcotest.test_case "timestamp precision is bounded" `Quick timestamp_precision_is_bounded; diff --git a/test/test_strategy_protocol.ml b/test/test_strategy_protocol.ml index b05ac12..9eab6ed 100644 --- a/test/test_strategy_protocol.ml +++ b/test/test_strategy_protocol.ml @@ -45,7 +45,7 @@ let initialize_message_is_complete () = T.Strategy_protocol.initialize_message ~sequence:1L (initialization ()) in Alcotest.(check string) - "protocol version" "9" + "protocol version" "10" (match field "strategy_protocol_version" message with | `String value -> value | _ -> Alcotest.fail "expected version string"); @@ -222,7 +222,7 @@ let nonpositive_equity_omits_weights () = let response message_type payload = `Assoc [ - ("strategy_protocol_version", `String "9"); + ("strategy_protocol_version", `String "10"); ("strategy_sequence", `String "3"); ("message_type", `String message_type); ("payload", payload);