diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..3351944 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,67 @@ +name: Bug report +description: Report reproducible incorrect engine, CLI, artifact, or contract behavior. +title: "fix: " +labels: ["bug"] +assignees: [] +body: + - type: markdown + attributes: + value: Thanks for providing a small, sanitized reproduction. + - type: dropdown + id: component + attributes: + label: Component + options: + - Accounting and valuation + - Artifacts and publication + - CLI and diagnostics + - Contracts and schemas + - Execution and order management + - Reducer and sequencing + - Risk and margin + - Strategy protocol + - CI and development tooling + validations: + required: true + - type: input + id: version + attributes: + label: Engine revision or version + placeholder: v1.1.0 or a full commit SHA + validations: + required: true + - type: input + id: contracts + attributes: + label: Contract versions + description: Include scenario, journal, diagnostic, and strategy versions that apply. + placeholder: scenario v1, journal v1, strategy v1 + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction + description: Provide exact commands and the smallest sanitized input or fixture. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: Include stable diagnostic codes and bounded output when available. + validations: + required: true + - type: checkboxes + id: safety + attributes: + label: Safe report + options: + - label: I removed credentials, customer data, proprietary strategies, and licensed data. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..4941f9c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security policy and private reporting + url: https://github.com/fallblu/trading-engine/blob/develop/.github/SECURITY.md + about: Review supported versions and report suspected vulnerabilities privately. + - name: Support and usage guidance + url: https://github.com/fallblu/trading-engine/blob/develop/.github/SUPPORT.md + about: Review supported scope, public boundaries, and safe issue-reporting guidance. diff --git a/.github/ISSUE_TEMPLATE/contract-change.yml b/.github/ISSUE_TEMPLATE/contract-change.yml new file mode 100644 index 0000000..df7e56f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/contract-change.yml @@ -0,0 +1,64 @@ +name: Contract change +description: Propose a versioned scenario, journal, diagnostic, capability, or strategy change. +title: "feat: evolve contract " +labels: ["enhancement", "component: contracts"] +assignees: [] +body: + - type: dropdown + id: family + attributes: + label: Contract family + options: + - Scenario and journal + - Scenario stream + - Capabilities + - Diagnostics + - External strategy protocol and transcript + validations: + required: true + - type: input + id: versions + attributes: + label: Affected versions + placeholder: current v1 and proposed change + validations: + required: true + - type: dropdown + id: compatibility + attributes: + label: Compatibility class + options: + - Additive within the current version + - New version required + - Clarification with no wire-format change + - Not yet known + validations: + required: true + - type: textarea + id: invariant + attributes: + label: Semantic invariant + description: State the runtime rule and what JSON Schema can and cannot enforce. + validations: + required: true + - type: textarea + id: wire-change + attributes: + label: Wire-format change + description: Show the smallest representative before-and-after records. + validations: + required: true + - type: textarea + id: migration + attributes: + label: Compatibility and migration + description: Explain frozen artifacts, parser behavior, and producer/consumer updates. + validations: + required: true + - type: textarea + id: verification + attributes: + label: Conformance evidence + description: List schemas, canonical fixtures, negative fixtures, and differential tests. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/cross-repository.yml b/.github/ISSUE_TEMPLATE/cross-repository.yml new file mode 100644 index 0000000..bc1e628 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/cross-repository.yml @@ -0,0 +1,59 @@ +name: Cross-repository compatibility +description: Report or propose coordinated behavior between Trading Engine and Persistra. +title: "chore: coordinate compatibility " +labels: ["enhancement", "dependency: persistra"] +assignees: [] +body: + - type: input + id: engine-revision + attributes: + label: Trading Engine revision + placeholder: Full commit SHA + validations: + required: true + - type: input + id: persistra-revision + attributes: + label: Persistra revision + placeholder: Full commit SHA + validations: + required: true + - type: input + id: contracts + attributes: + label: Contract versions + placeholder: scenario v1, journal v1, strategy v1 + validations: + required: true + - type: dropdown + id: owner + attributes: + label: Owning boundary + options: + - Trading Engine consumer/runtime behavior + - Persistra producer/host behavior + - Versioned contract shared by both repositories + - Not yet known + validations: + required: true + - type: textarea + id: behavior + attributes: + label: Compatibility behavior + description: Describe the expected handoff and the observed failure or proposed change. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction and evidence + description: Include exact commands, sanitized fixtures, diagnostics, and CI links. + validations: + required: true + - type: textarea + id: coordination + attributes: + label: Coordinated update + description: Identify which pin, tests, contracts, and documentation each repository changes. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..61c83f9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,60 @@ +name: Feature proposal +description: Propose one scoped capability without assigning a release commitment. +title: "feat: " +labels: ["enhancement"] +assignees: [] +body: + - type: dropdown + id: component + attributes: + label: Component + options: + - Accounting and valuation + - Artifacts and publication + - CLI and diagnostics + - Contracts and schemas + - Execution and order management + - Reducer and sequencing + - Risk and margin + - Strategy protocol + - CI and development tooling + validations: + required: true + - type: textarea + id: problem + attributes: + label: Problem + description: Describe the concrete unsupported workflow or invariant. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed behavior + description: Define scope, boundaries, and observable behavior without a delivery date. + validations: + required: true + - type: dropdown + id: contract-impact + attributes: + label: Contract impact + options: + - No public contract change + - Additive current-contract change + - Breaking versioned-contract change + - Not yet known + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives and tradeoffs + validations: + required: true + - type: textarea + id: verification + attributes: + label: Verification expectations + description: List unit, property, conformance, failure-path, or performance evidence. + validations: + required: true diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..1f7064f --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,52 @@ +# Security policy + +## Supported versions + +Trading Engine provides security fixes for the latest patch release in the current release line. + +| Release line | Supported | +| --- | --- | +| Latest 1.0.x patch | Yes | +| Earlier releases | No | + +The `develop` branch contains unreleased work and is not a supported release. A fix is staged there +or on a hotfix branch according to the repository's release workflow. This table is updated when a +new release line becomes supported. + +## Report a vulnerability privately + +Use [GitHub private vulnerability reporting](https://github.com/fallblu/trading-engine/security/advisories/new) +to report a suspected vulnerability. Do not open a public issue for an undisclosed vulnerability. + +A useful report includes the affected version or commit, security impact, trigger conditions, a +minimal sanitized reproduction, operating-system and dependency versions, and any disclosure +constraints. Do not include credentials, customer data, proprietary strategies, account details, +or licensed market data. Use synthetic inputs or describe the behavior when a safe reproduction +cannot be shared. + +The maintainer aims to acknowledge a report within three business days and provide an initial +assessment within seven business days. Remediation timing depends on severity, exploitability, and +release risk. These targets are goals, not guarantees. Keep the report private while it is being +assessed and fixed. The maintainer and reporter will coordinate public disclosure after a fix or +mitigation is available. The project does not currently offer a bug bounty. + +## Security boundaries + +An external strategy is an arbitrary executable, not a sandboxed plugin. The engine starts it +directly without a shell, but the child inherits the engine process's operating-system identity, +environment, filesystem access, network access, and standard error. Run only trusted strategies or +isolate them with an operating-system account, container, or sandbox that supplies the minimum +environment and permissions. Do not put secrets in strategy arguments, scenarios, or engine logs. + +Committed fixtures must contain only synthetic or redistributable data. Never add provider +credentials, customer account data, proprietary strategies, or licensed market data. Sanitize any +reproduction before sharing it in an issue, pull request, test, journal, or transcript. + +Journals and strategy transcripts can contain market events, orders, positions, diagnostics, and +a bounded prefix of a rejected strategy response. Store them according to the sensitivity of their +inputs and review them before sharing. Artifact hashes and release attestations provide integrity +and provenance; they do not encrypt data, enforce access control, or prove that an artifact is safe +to execute. + +Repository dependency and analysis controls are described in the +[security maintenance guide](../docs/security-maintenance.md). diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md new file mode 100644 index 0000000..98f7bac --- /dev/null +++ b/.github/SUPPORT.md @@ -0,0 +1,16 @@ +# Support + +Start with the [README](../README.md) for setup, supported scope, and command examples. The +[architecture](../docs/architecture.md), [scenario contract](../docs/scenario.md), and +[Persistra integration guide](../docs/persistra.md) describe the public boundaries in detail. + +Report a suspected vulnerability through the private channel in the +[security policy](SECURITY.md), not through a public issue. + +Use the structured issue forms for reproducible bugs, feature proposals, versioned contract +changes, and cross-repository compatibility failures. Search existing issues first. Include the +engine version, relevant contract versions, exact commands, sanitized inputs, and the smallest +reproduction that demonstrates the behavior. + +Do not post credentials, customer data, proprietary strategies, or licensed market data. This +repository cannot provide private trading, deployment, or strategy-development support. diff --git a/.github/branch-protection.json b/.github/branch-protection.json new file mode 100644 index 0000000..7c21939 --- /dev/null +++ b/.github/branch-protection.json @@ -0,0 +1,25 @@ +{ + "branches": { + "main": { + "required_status_checks": { + "strict": true, + "contexts": ["check", "persistra-compatibility"] + }, + "enforce_admins": true, + "required_pull_request_reviews": { + "dismiss_stale_reviews": false, + "require_code_owner_reviews": false, + "required_approving_review_count": 0, + "require_last_push_approval": false + }, + "restrictions": null, + "required_conversation_resolution": true, + "required_linear_history": true, + "allow_force_pushes": false, + "allow_deletions": false, + "block_creations": false, + "lock_branch": false, + "allow_fork_syncing": false + } + } +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2af0ef7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,35 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "America/New_York" + target-branch: "develop" + open-pull-requests-limit: 5 + groups: + github-actions: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + - package-ecosystem: "pip" + directory: "/requirements" + schedule: + interval: "weekly" + day: "monday" + time: "10:00" + timezone: "America/New_York" + target-branch: "develop" + open-pull-requests-limit: 5 + groups: + python-tooling: + patterns: + - "*" + update-types: + - "minor" + - "patch" diff --git a/.github/labels.json b/.github/labels.json new file mode 100644 index 0000000..a1e11d1 --- /dev/null +++ b/.github/labels.json @@ -0,0 +1,28 @@ +[ + {"category": "component", "name": "component: accounting", "color": "1d76db", "description": "Cash, positions, valuation, fees, and reconciliation"}, + {"category": "component", "name": "component: artifacts", "color": "1d76db", "description": "Journals, transcripts, manifests, and durable publication"}, + {"category": "component", "name": "component: ci", "color": "1d76db", "description": "Continuous integration, builds, and development tooling"}, + {"category": "component", "name": "component: cli", "color": "1d76db", "description": "Command-line input, output, diagnostics, and process behavior"}, + {"category": "component", "name": "component: contracts", "color": "1d76db", "description": "Versioned scenarios, journals, schemas, and conformance"}, + {"category": "component", "name": "component: execution", "color": "1d76db", "description": "Order management, matching, fills, and execution models"}, + {"category": "component", "name": "component: reducer", "color": "1d76db", "description": "Pure state transitions, phases, and deterministic sequencing"}, + {"category": "component", "name": "component: repository", "color": "1d76db", "description": "Repository policy, documentation, and community health"}, + {"category": "component", "name": "component: risk", "color": "1d76db", "description": "Pre-trade limits, margin, borrow, and liquidation"}, + {"category": "component", "name": "component: strategy", "color": "1d76db", "description": "Built-in and external strategy lifecycle and protocol"}, + + {"category": "priority", "name": "priority: critical", "color": "b60205", "description": "Immediate correctness, security, or data-integrity impact"}, + {"category": "priority", "name": "priority: high", "color": "d93f0b", "description": "Important work selected ahead of normal backlog items"}, + {"category": "priority", "name": "priority: medium", "color": "fbca04", "description": "Normal backlog priority after explicit triage"}, + {"category": "priority", "name": "priority: low", "color": "0e8a16", "description": "Useful work with no current urgency"}, + + {"category": "effort", "name": "effort: small", "color": "c2e0c6", "description": "Focused change with a narrow verification surface"}, + {"category": "effort", "name": "effort: medium", "color": "fef2c0", "description": "Multi-file change with moderate design or testing work"}, + {"category": "effort", "name": "effort: large", "color": "f9d0c4", "description": "Broad change that should be split into reviewed increments"}, + + {"category": "contract", "name": "contract: scenario-v1", "color": "5319e7", "description": "Current scenario and journal contract"}, + {"category": "contract", "name": "contract: strategy-v1", "color": "7057ff", "description": "Current external strategy protocol"}, + + {"category": "dependency", "name": "dependency: persistra", "color": "006b75", "description": "Requires coordinated behavior or validation in Persistra"}, + {"category": "dependency", "name": "dependency: upstream", "color": "006b75", "description": "Depends on an external project or toolchain"}, + {"category": "dependency", "name": "dependency: blocked", "color": "b60205", "description": "Cannot proceed until a named dependency is resolved"} +] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..83135b0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ +## Summary + + + +## Test plan + + diff --git a/.github/repository.json b/.github/repository.json new file mode 100644 index 0000000..0498cd5 --- /dev/null +++ b/.github/repository.json @@ -0,0 +1,18 @@ +{ + "description": "Deterministic OCaml trading replay engine", + "homepage": "https://fallblu.github.io/trading-engine/", + "topics": [ + "backtesting", + "deterministic", + "event-driven", + "execution-simulator", + "json-schema", + "ocaml", + "quantitative-finance", + "trading-engine" + ], + "allow_merge_commit": false, + "allow_rebase_merge": true, + "allow_squash_merge": false, + "delete_branch_on_merge": true +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be7945a..97e275a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,11 +2,26 @@ name: CI on: push: + branches: + - develop + tags: + - "*" pull_request: + workflow_dispatch: + inputs: + persistra_latest_head: + description: Run the nonrequired Persistra develop compatibility canary + required: false + default: false + type: boolean permissions: contents: read +concurrency: + group: ci-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.ref_type == 'branch' && !github.ref_protected) }} + jobs: check: runs-on: ubuntu-latest @@ -21,15 +36,118 @@ jobs: - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" - - run: uv venv .venv-schema - - run: >- - uv pip install --python .venv-schema/bin/python - "jsonschema[format-nongpl]==4.26.0" - - run: echo "$GITHUB_WORKSPACE/.venv-schema/bin" >> "$GITHUB_PATH" - - run: opam install . --deps-only --with-test --locked + - run: make bootstrap - run: make check + environment-matrix: + name: environment (${{ matrix.name }}) + continue-on-error: ${{ matrix.informational }} + strategy: + fail-fast: false + max-parallel: 2 + matrix: + include: + - name: lowest-ubuntu + runner: ubuntu-latest + dependency-band: lowest + target: dependency-band-check + informational: false + - name: highest-ubuntu + runner: ubuntu-latest + dependency-band: highest + target: dependency-band-check + informational: false + - name: highest-macos + runner: macos-15 + dependency-band: highest + target: determinism-check + informational: true + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ocaml/setup-ocaml@605a7e998e76e035b82c14d618a6e1010732c4ce # v3.7.1 + with: + ocaml-compiler: "5.5.0" + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: "3.12" + - name: Install ${{ matrix.dependency-band }} dependency band + run: ./scripts/bootstrap-development-environment "${{ matrix.dependency-band }}" + - name: Run ${{ matrix.target }} + run: make "${{ matrix.target }}" + + ocaml-coverage: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ocaml/setup-ocaml@605a7e998e76e035b82c14d618a6e1010732c4ce # v3.7.1 + with: + ocaml-compiler: "5.5.0" + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: "3.12" + - run: make bootstrap + - run: make coverage + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ocaml-coverage + path: | + _coverage/cobertura.xml + _coverage/html + _coverage/summary.txt + if-no-files-found: error + retention-days: 14 + persistra-compatibility: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + PERSISTRA_COMPAT_REVISION: 2f8beeaab87f4c456f7040f327314afe67306a54 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + path: trading-engine + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + repository: fallblu/persistra + ref: ${{ env.PERSISTRA_COMPAT_REVISION }} + path: persistra + - name: Report Persistra compatibility revision + working-directory: persistra + run: | + actual_revision="$(git rev-parse HEAD)" + test "$actual_revision" = "$PERSISTRA_COMPAT_REVISION" + echo "Persistra compatibility revision: $actual_revision" + printf '### Persistra compatibility\n\n`%s`\n' "$actual_revision" >> "$GITHUB_STEP_SUMMARY" + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: "3.12" + - uses: ocaml/setup-ocaml@605a7e998e76e035b82c14d618a6e1010732c4ce # v3.7.1 + with: + ocaml-compiler: "5.5.0" + - working-directory: trading-engine + run: make bootstrap + - working-directory: trading-engine + run: make build + - working-directory: persistra + run: uv sync --group dev + - working-directory: persistra + env: + PERSISTRA_TRADING_ENGINE_BINARY: ${{ github.workspace }}/trading-engine/_build/default/bin/main.exe + PERSISTRA_TRADING_ENGINE_CONTRACT_DIR: ${{ github.workspace }}/trading-engine/contracts/v1 + run: uv run pytest --no-cov tests/integration/test_trading_engine.py + + persistra-latest-head: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.persistra_latest_head }} + continue-on-error: true runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -41,8 +159,14 @@ jobs: with: persist-credentials: false repository: fallblu/persistra - ref: ${{ vars.PERSISTRA_COMPAT_REF || 'main' }} + ref: develop path: persistra + - name: Report Persistra canary revision + working-directory: persistra + run: | + actual_revision="$(git rev-parse HEAD)" + echo "Persistra latest-head canary revision: $actual_revision" + printf '### Persistra latest-head canary\n\n`%s`\n' "$actual_revision" >> "$GITHUB_STEP_SUMMARY" - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" @@ -50,7 +174,7 @@ jobs: with: ocaml-compiler: "5.5.0" - working-directory: trading-engine - run: opam install . --deps-only --with-test --locked + run: make bootstrap - working-directory: trading-engine run: make build - working-directory: persistra @@ -58,5 +182,5 @@ jobs: - working-directory: persistra env: PERSISTRA_TRADING_ENGINE_BINARY: ${{ github.workspace }}/trading-engine/_build/default/bin/main.exe - PERSISTRA_TRADING_ENGINE_CONTRACT_DIR: ${{ github.workspace }}/trading-engine/contracts/v3 + PERSISTRA_TRADING_ENGINE_CONTRACT_DIR: ${{ github.workspace }}/trading-engine/contracts/v1 run: uv run pytest --no-cov tests/integration/test_trading_engine.py diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..e3f3ba8 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,48 @@ +name: CodeQL + +on: + push: + branches: + - develop + - main + pull_request: + branches: + - develop + - main + schedule: + - cron: "17 13 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + analyze: + name: Analyze ${{ matrix.language }} + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: + - actions + - python + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + with: + languages: ${{ matrix.language }} + build-mode: none + queries: security-extended + - uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..3dbe194 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,32 @@ +name: Dependency review + +on: + pull_request: + branches: + - develop + - main + +permissions: + contents: read + +concurrency: + group: dependency-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + dependency-review: + name: Review dependency changes + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + fail-on-severity: moderate + fail-on-scopes: runtime, development, unknown + vulnerability-check: true + license-check: false + comment-summary-in-pr: never + show-openssf-scorecard: false diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..8a62cdf --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,94 @@ +name: Documentation + +on: + pull_request: + paths: + - .github/workflows/docs.yml + - .github/SECURITY.md + - .github/SUPPORT.md + - contracts/** + - docs/** + - dune + - dune-project + - lib/dune + - lib/**/*.mli + - requirements/docs.* + - scripts/bootstrap-documentation-environment + - scripts/build-documentation-site + - scripts/check-documentation.py + - CHANGELOG.md + - CONTRIBUTING.md + - Makefile + - README.md + - mkdocs.yml + - trading_engine.opam + - trading_engine.opam.locked + push: + branches: + - develop + paths: + - .github/workflows/docs.yml + - .github/SECURITY.md + - .github/SUPPORT.md + - contracts/** + - docs/** + - dune + - dune-project + - lib/dune + - lib/**/*.mli + - requirements/docs.* + - scripts/bootstrap-documentation-environment + - scripts/build-documentation-site + - scripts/check-documentation.py + - CHANGELOG.md + - CONTRIBUTING.md + - Makefile + - README.md + - mkdocs.yml + - trading_engine.opam + - trading_engine.opam.locked + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: documentation-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + name: documentation-build + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ocaml/setup-ocaml@605a7e998e76e035b82c14d618a6e1010732c4ce # v3.7.1 + with: + ocaml-compiler: "5.5.0" + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: "3.12" + - run: make bootstrap + - run: make docs-build + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: site + + deploy: + name: documentation-deploy + if: github.event_name == 'push' && github.ref == 'refs/heads/develop' + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + - id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/external-links.yml b/.github/workflows/external-links.yml new file mode 100644 index 0000000..e48646a --- /dev/null +++ b/.github/workflows/external-links.yml @@ -0,0 +1,62 @@ +name: External links + +on: + pull_request: + paths: + - .github/workflows/external-links.yml + - .github/SECURITY.md + - .github/SUPPORT.md + - contracts/**/*.md + - docs/**/*.md + - CHANGELOG.md + - CONTRIBUTING.md + - README.md + - lychee.toml + push: + branches: + - develop + paths: + - .github/workflows/external-links.yml + - .github/SECURITY.md + - .github/SUPPORT.md + - contracts/**/*.md + - docs/**/*.md + - CHANGELOG.md + - CONTRIBUTING.md + - README.md + - lychee.toml + schedule: + - cron: "17 7 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: external-links-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + check: + name: external-links + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0 + with: + args: >- + --config lychee.toml --verbose --no-progress + 'README.md' 'CONTRIBUTING.md' 'CHANGELOG.md' + '.github/SECURITY.md' '.github/SUPPORT.md' + 'docs/**/*.md' 'contracts/**/README.md' + checkbox: false + fail: true + failIfEmpty: true + format: detailed + jobSummary: false + lycheeVersion: v0.24.2 + output: lychee-report.txt + token: "" diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml new file mode 100644 index 0000000..86870a7 --- /dev/null +++ b/.github/workflows/release-candidate.yml @@ -0,0 +1,131 @@ +name: Release candidate + +on: + pull_request: + paths: + - .github/workflows/release-candidate.yml + - .github/SECURITY.md + - bin/** + - contracts/** + - docs/** + - lib/** + - requirements/** + - scripts/bootstrap-development-environment + - scripts/bootstrap-documentation-environment + - scripts/build-documentation-site + - scripts/build-release-artifacts + - scripts/check-release-artifacts + - scripts/release_artifacts.py + - CHANGELOG.md + - LICENSE + - Makefile + - README.md + - dune + - dune-project + - mkdocs.yml + - trading_engine.opam + - trading_engine.opam.locked + push: + branches: + - develop + paths: + - .github/workflows/release-candidate.yml + - .github/SECURITY.md + - bin/** + - contracts/** + - docs/** + - lib/** + - requirements/** + - scripts/bootstrap-development-environment + - scripts/bootstrap-documentation-environment + - scripts/build-documentation-site + - scripts/build-release-artifacts + - scripts/check-release-artifacts + - scripts/release_artifacts.py + - CHANGELOG.md + - LICENSE + - Makefile + - README.md + - dune + - dune-project + - mkdocs.yml + - trading_engine.opam + - trading_engine.opam.locked + workflow_dispatch: + inputs: + version: + description: Existing exact tag version without the v prefix + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-candidate-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + candidate: + name: release-candidate + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + RELEASE_VERSION: ${{ inputs.version || '' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Validate manual tag boundary + if: github.event_name == 'workflow_dispatch' + run: test "$GITHUB_REF" = "refs/tags/v$RELEASE_VERSION" + - uses: ocaml/setup-ocaml@605a7e998e76e035b82c14d618a6e1010732c4ce # v3.7.1 + with: + ocaml-compiler: "5.5.0" + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + cache-dependency-glob: requirements/*.lock + python-version: "3.12" + - run: make bootstrap + - run: make release-check VERSION="$RELEASE_VERSION" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-candidate-${{ github.sha }} + path: release + if-no-files-found: error + retention-days: 14 + + attest: + name: release-attestations + if: github.event_name == 'workflow_dispatch' + needs: candidate + runs-on: ubuntu-latest + environment: release + permissions: + artifact-metadata: write + attestations: write + contents: read + id-token: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-candidate-${{ github.sha }} + path: release + - id: provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-checksums: release/SUBJECTS.sha256 + - id: sbom + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-checksums: release/SUBJECTS.sha256 + sbom-path: release/sbom.spdx.json + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-attestations-${{ github.sha }} + path: | + ${{ steps.provenance.outputs.bundle-path }} + ${{ steps.sbom.outputs.bundle-path }} + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore index 58211b8..940ca49 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,13 @@ /_build/ +/_build-coverage/ +/_coverage/ +/benchmark-results/ +__pycache__/ /_opam/ /.venv-schema/ +/.venv-docs/ +/site/ +/release/ /.direnv/ /.envrc *.install diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b95cfc..45ff8dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,11 @@ # Changelog -## 1.0.0 — 2026-08-21 +## [1.1.0] - 2026-08-26 -- Release the deterministic completed-bar execution engine with exact checked arithmetic, - causal audit journals, and versioned JSON and JSON Lines contracts. -- Support portfolio targets, direct orders, partial fills, fees, multi-currency accounting, - corporate actions, borrow costs, margin controls, and deterministic liquidation. -- Add synchronous external strategies through protocol v3 with current-slice callback state and - strategy responses applied before matching continues. -- Provide strict schemas, conformance fixtures, replay validation, and Persistra compatibility - checks. +- Reset the scenario, journal, strategy, execution-configuration, CLI, and diagnostic contracts to + one authoritative v1 surface. +- Remove historical schemas, compatibility dispatch, deprecated constructors, frozen fixtures, + and obsolete compatibility tests. +- Require explicit initial portfolios, current risk and fee policies, complete market slices, and + current strategy initialization. +- Simplify repository and contract documentation around the current engine boundary. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6671ece..14bca8f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,28 +1,84 @@ # Contributing -Use the repository-local opam switch and install development dependencies: +Install `opam`, `uv`, and Python 3, then bootstrap the repository-local development +environment: ```sh -opam install . --deps-only --with-test --locked +make bootstrap ``` -The schema conformance tests also require Python 3 and the JSON Schema format -validators: +The command creates or updates only the repository-local opam switch and +`.venv-schema`. It installs the locked OCaml dependencies and the fully pinned JSON +Schema validator environment. It is safe to run again after either lock changes. + +Check an existing environment without changing it: ```sh -python3 -m venv .venv-schema -.venv-schema/bin/python -m pip install 'jsonschema[format-nongpl]==4.26.0' -export PATH="$PWD/.venv-schema/bin:$PATH" +make environment-check ``` +The check reports missing tools, a missing or incorrect local opam switch, stale +locked dependencies, and an incomplete schema environment with a suggested repair. + Run the complete local gate before committing: ```sh make check ``` +The gate includes the fixed protocol-fuzzing smoke corpus. For longer deterministic +campaigns and reproduction controls, see [Protocol fuzzing](docs/fuzzing.md). +Reducer model properties also print reproducible seeds and shrink failures into scenario-like +traces; see [Reducer property testing](docs/reducer-property-testing.md). + +Run `make coverage` to enforce the OCaml coverage floor and generate per-module, control-flow +HTML, and Cobertura reports. See [OCaml coverage](docs/coverage.md) for report locations, +instrumentation scope, and the explained-threshold-change policy. + +CI additionally resolves the lowest and highest supported dependency bands and compares canonical +journal bytes on Linux and macOS. See [Continuous integration](docs/continuous-integration.md) for +the required and informational cells. Use `make dependency-band-check` only after bootstrapping a +nonlocked CI band; normal development continues to use `make check` and the exact lock. + +Run `make docs-build` to create the strict local site under `site/`. It installs only the locked +documentation tools in `.venv-docs`, stages versioned contracts without modifying them, generates +the public OCaml interfaces, and validates the complete output. See the +[documentation platform](docs/documentation-platform.md) for publication and link-checking policy. + +Run `make release-check` only from a clean tracked revision to reproduce the complete candidate +artifact set twice and verify its install. This never tags or publishes. See +[release artifacts and provenance](docs/release-artifacts.md) for the human approval boundary. + The gate formats a copy check, builds every target, and runs all tests. Keep commits small, coherent, and working. Use subject-only conventional commit messages such as `feat: implement deterministic order matching`. +## Git workflow + +Create feature branches from `develop` and open pull requests back into `develop`. Use +rebase-and-merge so every coherent commit remains visible; do not use squash or merge commits. +GitHub deletes merged head branches automatically, so verify that the branch is gone afterward. + +Promotion to `main` also uses a pull request and rebase merge. The protected branch requires a head +that is current with `main`, resolved review conversations, and successful `check` and +`persistra-compatibility` jobs. It blocks force pushes and branch deletion and applies to +administrators without a bypass. The rule requires no approval while the repository has one +maintainer, avoiding a self-review deadlock. See [Repository governance](docs/repository-governance.md) +for the complete policy. + Do not add secrets, provider credentials, or customer account data to fixtures or journals. +Report suspected vulnerabilities through the private channel in the +[security policy](.github/SECURITY.md), not through a public issue. + +## Intake and planning metadata + +Use the structured bug, feature, contract-change, or cross-repository issue form. Pull requests +retain the `Summary` and `Test plan` sections from the repository template. + +Component, contract-version, and dependency labels describe stable scope. Priority and effort +labels are assigned only during explicit triage; they do not promise a release, date, or roadmap +position. Do not encode delivery commitments in labels. The reviewed label definitions and desired +repository metadata live under `.github/` and must agree with the GitHub settings. + +For reciprocal Persistra compatibility guarantees and the pin-advancement procedure, read +[Persistra integration](docs/persistra.md#compatibility-gate). diff --git a/Makefile b/Makefile index 5bb2738..b847ff4 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,16 @@ -.PHONY: build test fmt-check check +export PATH := $(CURDIR)/.venv-schema/bin:$(PATH) + +.PHONY: bootstrap environment-check build test metadata-check docs-bootstrap docs-source-check docs-check docs-build release-build release-check determinism-check dependency-band-check coverage benchmark-smoke benchmark fuzz-smoke fuzz fmt-check check + +FUZZ_SEED ?= 20260821 +FUZZ_CASES ?= 10000 +BENCHMARK_OUTPUT ?= benchmark-results/replay.json + +bootstrap: + @./scripts/bootstrap-development-environment + +environment-check: + @./scripts/check-development-environment build: opam exec -- dune build @all @@ -6,7 +18,47 @@ build: test: opam exec -- dune runtest +metadata-check: + python3 test/test_repository_metadata.py + +docs-bootstrap: + @./scripts/bootstrap-documentation-environment + +docs-source-check: + python3 scripts/check-documentation.py source + +docs-check: docs-source-check + +docs-build: docs-bootstrap docs-check + @./scripts/build-documentation-site + +release-build: environment-check + @./scripts/build-release-artifacts "$(CURDIR)/release" "$(VERSION)" + +release-check: environment-check + @VERSION="$(VERSION)" ./scripts/check-release-artifacts + +determinism-check: build + @./scripts/check-deterministic-journals + +dependency-band-check: fmt-check build test metadata-check determinism-check benchmark-smoke + +coverage: environment-check + @./scripts/check-ocaml-coverage + +benchmark-smoke: build + python3 bench/benchmark_replay.py --suite smoke --repetitions 1 --warmups 0 + +benchmark: build + python3 bench/benchmark_replay.py --output $(BENCHMARK_OUTPUT) + +fuzz-smoke: + opam exec -- dune exec test/fuzz_protocol.exe -- --seed 20260821 --cases 256 + +fuzz: + opam exec -- dune exec test/fuzz_protocol.exe -- --seed $(FUZZ_SEED) --cases $(FUZZ_CASES) + fmt-check: opam exec -- dune build @fmt -check: fmt-check build test +check: environment-check fmt-check build test metadata-check docs-source-check determinism-check benchmark-smoke diff --git a/README.md b/README.md index a86543f..951f565 100644 --- a/README.md +++ b/README.md @@ -1,193 +1,76 @@ # Trading Engine -Trading Engine is a deterministic, event-driven OCaml execution engine. It runs a typed strategy -through pre-trade risk, order management, synchronized completed-bar execution, exact accounting, -valuation, and a hash-bound JSON Lines audit journal. - -The engine is replay-first. Its pure kernel and explicit source, strategy, execution, and journal -layers keep networking, files, and wall-clock state outside the reducer. - -```text -scenario slices and scheduled or external intents - │ - ▼ - deterministic reducer - │ - ┌──────────┼──────────┐ - ▼ ▼ ▼ - strategy risk + OMS slice simulator - │ │ │ - └──────────┴──── fills┘ - │ - ▼ - accounting + valuation - │ - ▼ - JSON Lines journal -``` +Trading Engine is a deterministic OCaml engine for replaying trading strategies. + +It accepts a strict scenario, runs strategy decisions through risk, order management, execution, +financing, settlement, and accounting, then writes a hash-bound JSON Lines audit journal. The same +reducer supports scheduled intents and supervised external strategy processes. + +## Highlights -## Implemented scope - -- OCaml 5.5 and Dune 3.24 with a repository-local opam switch -- Opaque IDs and canonical checked fixed-point prices, weights, money, and quantities -- Synchronized market slices with one bar per configured instrument -- Separate market event, availability, receipt, slice, and engine ordering -- Pure strategy callbacks with causal, immutable context snapshots -- Pure suspend/resume strategy requests with equivalent scripted and external reducers -- Portfolio weight and quantity targets covering the complete instrument catalog -- Current-equity weight sizing at synchronized closing marks with lot rounding -- Persistent target reconciliation through bounded market-order attempts -- Direct market and limit orders, cancellations, and metrics -- Signed long/short position, order, lot, tick, gross-exposure, leverage, and margin risk -- Deterministic liquidation-first matching, then sell-before-buy and FIFO priority -- Shared per-instrument volume participation, partial fills, and GTC limits -- One-slice IOC market orders -- Risk-aware fractional-lot clipping with structured `margin_limited` records -- Fixed and notional fees with explicit rounding -- Explicit multi-currency cash ledgers and complete per-slice FX marks in a base currency -- Split and cash-dividend processing before matching, including target and order adjustment -- Short borrow accrual, maintenance-margin calls, and deterministic liquidation orders -- Signed average-cost accounting, realized and unrealized P&L, and equity reconciliation -- Per-currency cash and per-instrument quantity, mark, value, basis, P&L, and fee attribution -- Deterministic event IDs, ordered causal references, and order-creation attribution -- Contract-selected compiled execution modules; v3 currently exposes `completed_bar_v1` -- 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 -- Complete bidirectional strategy transcripts with exclusive partial and no-replace publication -- Scenario SHA-256 binding in `run_started` and `run_completed` -- Exclusive partial journal creation and atomic no-replace finalization -- Unit, schema-conformance, scenario, golden-contract, and property tests +- Exact fixed-point prices, quantities, money, weights, and FX rates +- Completed-bar, conservative-bar, quote/trade, and order-book execution models +- Market, limit, stop, stop-limit, IOC, GTC, GTD, DAY, and FOK orders +- Instrument and portfolio risk, margin, short locates, recalls, and liquidation +- Multi-currency accounting, financing, settlement, fees, and corporate actions +- Deterministic event IDs, causal references, transcripts, and durable artifact publication +- Strict v1 JSON Schemas for scenarios, journals, strategy messages, diagnostics, and CLI results ## Quick start -The project uses a local switch and does not modify the default switch. The complete check also -uses Python's `jsonschema` package to validate the committed scenario and journal fixtures. +The repository uses a local opam switch. ```sh -cd ~/trading-engine -opam switch set . -opam install . --deps-only --with-test --locked +make bootstrap make check ``` -Validate the included scenario with an in-memory replay: +Validate the canonical scenario: ```sh opam exec -- dune exec trading-engine -- \ - --input contracts/v3/fixtures/demo.scenario.json \ + --input contracts/v1/fixtures/demo.scenario.json \ --validate-only ``` -Run it and create a journal: +Replay it to a journal: ```sh opam exec -- dune exec trading-engine -- \ - --input contracts/v3/fixtures/demo.scenario.json \ + --input contracts/v1/fixtures/demo.scenario.json \ --journal demo.journal.jsonl ``` -For larger histories, validate and replay the equivalent stream one slice at a time: +Use `--input-format jsonl` for bounded-memory stream input. Use `--capabilities` for the +machine-readable runtime surface and `--output-format json` for structured success and failure +output. -```sh -opam exec -- dune exec trading-engine -- \ - --input contracts/v3/fixtures/demo.scenario.jsonl \ - --input-format jsonl \ - --journal demo.journal.jsonl -``` +## External strategies -Run an external strategy against an empty-schedule scenario: +External strategies exchange one synchronous JSON Lines message at a time: ```sh opam exec -- dune exec trading-engine -- \ - --input contracts/strategy/v3/fixtures/external.scenario.json \ + --input contracts/strategy/v1/fixtures/external.scenario.json \ --journal external.journal.jsonl \ --strategy-executable ./my-strategy \ - --strategy-arg=config.toml \ - --strategy-timeout 30 \ --strategy-transcript external.strategy.jsonl ``` -The engine launches the program directly without a shell. This supervises the child but does not -sandbox it; run strategy code with the same trust you give the invoking user. Protocol messages -own the child's standard input and output; strategy diagnostics belong on standard error. Only -one request is outstanding. Initialization must return `ready`, each event must return `intents`, -and shutdown must return `stopped`. Wrong versions or sequences, unknown or malformed fields, -oversized responses, EOF, timeout, extra output, and nonzero exit all fail the replay. The journal -and transcript retain partial artifacts for diagnosis. - -Discover the executable version and machine-readable compatibility surface: - -```sh -opam exec -- dune exec trading-engine -- --version -opam exec -- dune exec trading-engine -- --capabilities -``` +The child process is supervised but not sandboxed. Protocol output belongs on standard output; +strategy logs belong on standard error. -Clients must confirm that both `scenario_contract_versions` and `journal_contract_versions` -contain the scenario's `contract_version` before starting a replay. External clients must also -require their version in `strategy_protocol_versions`. - -The final and `.partial` journal paths must not already exist. Batch JSON hashes the same complete -document it parses. JSON Lines input is hashed and validated in a bounded-memory pass before the -journal is created, then replayed from the same open file and hashed again before publication. The -CLI binds that exact-byte hash into the journal. It writes to the partial path and publishes the -requested path only after `run_completed` is fully written and the partial file is closed. An -error preserves the partial artifact for diagnosis. - -## Execution summary - -An order emitted after slice `n` cannot execute on slice `n`. It first becomes eligible on a later -slice whose start is not earlier than its creation time. - -- Market orders attempt the next eligible open and cancel any remainder after that slice. -- Persistent portfolio targets submit a new bounded attempt after each miss until reached or - superseded. -- Limit orders use deterministic gap improvement and optimistic intrabar touch rules. -- 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. -- Borrow fees accrue on open shorts for the slice interval before matching. -- Proposed fills are clipped to the largest permitted fractional-lot quantity at the actual fill - price and never exceed the maximum order quantity. Increasing exposure must satisfy position, - gross-exposure, leverage, and initial-margin limits; exposure-reducing fills remain available. -- A maintenance-margin breach cancels active orders, clears portfolio targets, and creates - deterministic market orders that flatten positions in bounded lots across later slices. -- The engine emits exactly one valuation after each complete synchronized slice. - -Read [Execution model](docs/execution-model.md) for the full phase, price, fee, cash, and accounting -rules. - -## Project boundaries - -The current scope omits: - -- Broker and streaming-market-data connectors -- External execution-report ingestion -- Exchange calendars and time-zone databases -- Durable reducer snapshots and broker reconciliation -- `fsync` and restart recovery for journals -- Tick, trade, and order-book replay - -The journal writer flushes each record, creates its partial file exclusively, and finalizes with an -exclusive hard link. It does not call `fsync`, so the journal is an audit artifact rather than a -production recovery log. - -## Architecture and contracts +## Documentation - [Architecture](docs/architecture.md) -- [Scenario contract](docs/scenario.md) -- [Current contract v3 and conformance fixtures](contracts/v3/README.md) -- [Frozen contract v2](contracts/v2/README.md) -- [Historical contract v1](contracts/v1/README.md) -- [Scenario JSON Schema](contracts/v3/scenario.schema.json) -- [Scenario stream record JSON Schema](contracts/v3/scenario-stream.schema.json) -- [Journal record JSON Schema](contracts/v3/journal.schema.json) -- [External 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) -- [Strategy message JSON Schema](contracts/strategy/v3/message.schema.json) -- [Strategy transcript JSON Schema](contracts/strategy/v3/transcript.schema.json) - [Execution model](docs/execution-model.md) +- [Scenario and journal](docs/scenario.md) +- [Replay contract v1](contracts/v1/README.md) +- [Strategy protocol v1](contracts/strategy/v1/README.md) +- [Diagnostics](docs/diagnostics.md) - [Persistra integration](docs/persistra.md) - [Contributing](CONTRIBUTING.md) +- [Security](.github/SECURITY.md) + +The complete documentation site is published at +[fallblu.github.io/trading-engine](https://fallblu.github.io/trading-engine/). diff --git a/bench/baselines/linux-x86_64.json b/bench/baselines/linux-x86_64.json new file mode 100644 index 0000000..71269aa --- /dev/null +++ b/bench/baselines/linux-x86_64.json @@ -0,0 +1,159 @@ +{ + "schema_version": 1, + "captured_at": "2026-08-21", + "environment": { + "system": "Linux/WSL2", + "machine": "Intel Core i7-10750H x86_64", + "python": "3.12.3", + "engine": "1.0.0", + "dune_profile": "dev", + "repetitions": 3, + "warmups": 1 + }, + "policy": { + "mode": "advisory", + "rationale": "Performance gates remain opt-in until multiple runners establish stable distributions." + }, + "cases": { + "batch-standard": { + "metrics": { + "median_wall_seconds": 0.03820421, + "median_peak_rss_kib": 18176, + "median_events_per_second": 26227.475977, + "median_artifact_bytes_per_second": 24804282.041168 + }, + "tolerances": { + "median_wall_seconds": 0.35, + "median_peak_rss_kib": 0.25, + "median_events_per_second": 0.30, + "median_artifact_bytes_per_second": 0.30 + } + }, + "stream-standard": { + "metrics": { + "median_wall_seconds": 0.0729861, + "median_peak_rss_kib": 18276, + "median_events_per_second": 13728.641481, + "median_artifact_bytes_per_second": 13017999.317678 + }, + "tolerances": { + "median_wall_seconds": 0.35, + "median_peak_rss_kib": 0.25, + "median_events_per_second": 0.30, + "median_artifact_bytes_per_second": 0.30 + } + }, + "batch-large-catalog": { + "metrics": { + "median_wall_seconds": 0.36514346, + "median_peak_rss_kib": 28792, + "median_events_per_second": 553.207224, + "median_artifact_bytes_per_second": 20197740.909833 + }, + "tolerances": { + "median_wall_seconds": 0.35, + "median_peak_rss_kib": 0.25, + "median_events_per_second": 0.30, + "median_artifact_bytes_per_second": 0.30 + } + }, + "stream-large-catalog": { + "metrics": { + "median_wall_seconds": 0.61600429, + "median_peak_rss_kib": 23908, + "median_events_per_second": 327.919794, + "median_artifact_bytes_per_second": 11973257.523905 + }, + "tolerances": { + "median_wall_seconds": 0.35, + "median_peak_rss_kib": 0.25, + "median_events_per_second": 0.30, + "median_artifact_bytes_per_second": 0.30 + } + }, + "batch-dense-oms": { + "metrics": { + "median_wall_seconds": 0.0348315, + "median_peak_rss_kib": 23908, + "median_events_per_second": 13149.017412, + "median_artifact_bytes_per_second": 11377230.380546 + }, + "tolerances": { + "median_wall_seconds": 0.35, + "median_peak_rss_kib": 0.25, + "median_events_per_second": 0.30, + "median_artifact_bytes_per_second": 0.30 + } + }, + "stream-dense-oms": { + "metrics": { + "median_wall_seconds": 0.06674844, + "median_peak_rss_kib": 23908, + "median_events_per_second": 6861.583582, + "median_artifact_bytes_per_second": 5967585.160043 + }, + "tolerances": { + "median_wall_seconds": 0.35, + "median_peak_rss_kib": 0.25, + "median_events_per_second": 0.30, + "median_artifact_bytes_per_second": 0.30 + } + }, + "external-batch-zero-latency": { + "metrics": { + "median_wall_seconds": 0.06543207, + "median_peak_rss_kib": 23908, + "median_events_per_second": 3087.171169, + "median_artifact_bytes_per_second": 5267310.051478 + }, + "tolerances": { + "median_wall_seconds": 0.35, + "median_peak_rss_kib": 0.25, + "median_events_per_second": 0.30, + "median_artifact_bytes_per_second": 0.30 + } + }, + "external-stream-zero-latency": { + "metrics": { + "median_wall_seconds": 0.07694489, + "median_peak_rss_kib": 23908, + "median_events_per_second": 2625.255556, + "median_artifact_bytes_per_second": 4485781.966808 + }, + "tolerances": { + "median_wall_seconds": 0.35, + "median_peak_rss_kib": 0.25, + "median_events_per_second": 0.30, + "median_artifact_bytes_per_second": 0.30 + } + }, + "external-batch-five-ms": { + "metrics": { + "median_wall_seconds": 0.66649209, + "median_peak_rss_kib": 23908, + "median_events_per_second": 303.079366, + "median_artifact_bytes_per_second": 513308.417509 + }, + "tolerances": { + "median_wall_seconds": 0.35, + "median_peak_rss_kib": 0.25, + "median_events_per_second": 0.30, + "median_artifact_bytes_per_second": 0.30 + } + }, + "external-stream-five-ms": { + "metrics": { + "median_wall_seconds": 0.68144857, + "median_peak_rss_kib": 23908, + "median_events_per_second": 296.427359, + "median_artifact_bytes_per_second": 502786.292442 + }, + "tolerances": { + "median_wall_seconds": 0.35, + "median_peak_rss_kib": 0.25, + "median_events_per_second": 0.30, + "median_artifact_bytes_per_second": 0.30 + } + } + } +} diff --git a/bench/benchmark_batch_schedule.py b/bench/benchmark_batch_schedule.py new file mode 100644 index 0000000..14d62b6 --- /dev/null +++ b/bench/benchmark_batch_schedule.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Benchmark dense batch-schedule validation through the public CLI.""" + +from __future__ import annotations + +import argparse +import copy +import json +import statistics +import subprocess +import tempfile +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_EXECUTABLE = ROOT / "_build/default/bin/main.exe" +FIXTURE = ROOT / "contracts/v1/fixtures/demo.scenario.json" + + +def timestamp(value: datetime) -> str: + return value.isoformat(timespec="seconds").replace("+00:00", "Z") + + +def dense_scenario(size: int) -> dict[str, object]: + document = json.loads(FIXTURE.read_text(encoding="utf-8")) + template = document["slices"][0] + base = datetime(2026, 2, 1, tzinfo=timezone.utc) + slices = [] + schedule = [] + for offset in range(size): + sequence = offset + 1 + start = base + timedelta(seconds=offset * 4) + market_slice = copy.deepcopy(template) + market_slice.update( + { + "slice_sequence": str(sequence), + "start_at": timestamp(start), + "end_at": timestamp(start + timedelta(seconds=1)), + "available_at": timestamp(start + timedelta(seconds=2)), + "received_at": timestamp(start + timedelta(seconds=3)), + "corporate_actions": [], + } + ) + slices.append(market_slice) + schedule.append( + { + "after_slice_sequence": str(sequence), + "intents": [ + { + "type": "emit_metric", + "name": "dense_schedule", + "value": {"type": "numeric", "value": str(sequence)}, + "unit": None, + "dimensions": {}, + "aggregation": "last", + } + ], + } + ) + document["schedule"] = schedule + document["slices"] = slices + return document + + +def measure(executable: Path, scenario: Path, repetitions: int) -> list[float]: + durations = [] + command = [str(executable), "--input", str(scenario), "--validate-only"] + for _ in range(repetitions): + started = time.perf_counter() + subprocess.run( + command, + cwd=ROOT, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + durations.append(time.perf_counter() - started) + return durations + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--executable", type=Path, default=DEFAULT_EXECUTABLE + ) + parser.add_argument( + "--sizes", type=int, nargs="+", default=[5_000, 10_000, 20_000] + ) + parser.add_argument("--repetitions", type=int, default=3) + args = parser.parse_args() + if args.repetitions <= 0 or any(size <= 0 for size in args.sizes): + parser.error("sizes and repetitions must be positive") + executable = args.executable.resolve() + if not executable.is_file(): + parser.error(f"executable does not exist: {executable}") + + print("slices,schedule,repetitions,median_seconds,min_seconds,max_seconds") + for size in args.sizes: + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", suffix=".scenario.json" + ) as scenario: + json.dump(dense_scenario(size), scenario, separators=(",", ":")) + scenario.flush() + durations = measure(executable, Path(scenario.name), args.repetitions) + print( + f"{size},{size},{args.repetitions}," + f"{statistics.median(durations):.6f},{min(durations):.6f}," + f"{max(durations):.6f}" + ) + + +if __name__ == "__main__": + main() diff --git a/bench/benchmark_replay.py b/bench/benchmark_replay.py new file mode 100644 index 0000000..8b73970 --- /dev/null +++ b/bench/benchmark_replay.py @@ -0,0 +1,582 @@ +#!/usr/bin/env python3 +"""Benchmark representative batch, stream, OMS, and strategy workloads.""" + +from __future__ import annotations + +import argparse +import copy +import json +import os +import platform +import re +import statistics +import subprocess +import sys +import tempfile +import time +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_EXECUTABLE = ROOT / "_build/default/bin/main.exe" +DEFAULT_BASELINE = ROOT / "bench/baselines/linux-x86_64.json" +FIXTURE = ROOT / "contracts/v1/fixtures/demo.scenario.json" +STRATEGY = ROOT / "bench/latency_strategy.py" +SUMMARY_PATTERN = re.compile( + r"\baudits=(?P[0-9]+).*\bactive=(?P[0-9]+)" +) +METRIC_DIRECTIONS = { + "median_wall_seconds": "higher", + "median_peak_rss_kib": "higher", + "median_events_per_second": "lower", + "median_artifact_bytes_per_second": "lower", +} + + +@dataclass(frozen=True) +class BenchmarkCase: + name: str + replay_format: str + catalog_size: int + slice_count: int + active_order_count: int = 0 + strategy_latency_ms: float | None = None + + @property + def uses_external_strategy(self) -> bool: + return self.strategy_latency_ms is not None + + +@dataclass(frozen=True) +class Sample: + wall_seconds: float + peak_rss_kib: int + audit_events: int + artifact_bytes: int + events_per_second: float + artifact_bytes_per_second: float + + +SMOKE_CASES = ( + BenchmarkCase("smoke-batch", "batch", 2, 8, active_order_count=4), + BenchmarkCase("smoke-stream", "stream", 2, 8, active_order_count=4), + BenchmarkCase("smoke-external-batch", "batch", 1, 4, strategy_latency_ms=1.0), + BenchmarkCase("smoke-external-stream", "stream", 1, 4, strategy_latency_ms=1.0), +) + +FULL_CASES = ( + BenchmarkCase("batch-standard", "batch", 1, 500), + BenchmarkCase("stream-standard", "stream", 1, 500), + BenchmarkCase("batch-large-catalog", "batch", 128, 100), + BenchmarkCase("stream-large-catalog", "stream", 128, 100), + BenchmarkCase("batch-dense-oms", "batch", 1, 100, active_order_count=256), + BenchmarkCase("stream-dense-oms", "stream", 1, 100, active_order_count=256), + BenchmarkCase("external-batch-zero-latency", "batch", 1, 100, strategy_latency_ms=0.0), + BenchmarkCase("external-stream-zero-latency", "stream", 1, 100, strategy_latency_ms=0.0), + BenchmarkCase("external-batch-five-ms", "batch", 1, 100, strategy_latency_ms=5.0), + BenchmarkCase("external-stream-five-ms", "stream", 1, 100, strategy_latency_ms=5.0), +) + + +def timestamp(value: datetime) -> str: + return value.isoformat(timespec="seconds").replace("+00:00", "Z") + + +def _instrument(index: int) -> dict[str, object]: + return { + "instrument_id": f"benchmark-equity-{index:04d}", + "symbol": f"B{index:04d}", + "quote_currency": "USD", + "tick_size": "0.01", + "lot_size": "1", + } + + +def build_scenario(case: BenchmarkCase) -> dict[str, object]: + """Build a deterministic scenario whose declared dimensions match a case.""" + if case.replay_format not in {"batch", "stream"}: + raise ValueError("replay_format must be batch or stream") + if case.catalog_size <= 0 or case.slice_count <= 0: + raise ValueError("catalog_size and slice_count must be positive") + if not 0 <= case.active_order_count <= 4096: + raise ValueError("active_order_count must be between 0 and 4096") + if case.uses_external_strategy and case.active_order_count: + raise ValueError("external-strategy cases cannot contain a schedule") + + document = json.loads(FIXTURE.read_text(encoding="utf-8")) + template = document["slices"][0] + instruments = [_instrument(index + 1) for index in range(case.catalog_size)] + base = datetime(2026, 8, 21, tzinfo=timezone.utc) + slices = [] + for offset in range(case.slice_count): + start = base + timedelta(seconds=offset * 4) + market_slice = copy.deepcopy(template) + market_slice.update( + { + "slice_sequence": str(offset + 1), + "start_at": timestamp(start), + "end_at": timestamp(start + timedelta(seconds=1)), + "available_at": timestamp(start + timedelta(seconds=2)), + "received_at": timestamp(start + timedelta(seconds=3)), + "bars": [ + { + "instrument_id": instrument["instrument_id"], + "open": "100", + "high": "101", + "low": "99", + "close": "100", + "volume": "1000000", + } + for instrument in instruments + ], + "corporate_actions": [], + "borrow_observations": [ + { + "instrument_id": instrument["instrument_id"], + "effective_at": timestamp(start), + "available_quantity": "1000000", + "annual_rate_bps": 0, + "recalled": False, + } + for instrument in instruments + ], + "cash_rate_observations": [ + { + "currency": "USD", + "effective_at": timestamp(start), + "credit_rate_bps": 0, + "debit_rate_bps": 0, + } + ], + } + ) + slices.append(market_slice) + + schedule = [] + if case.active_order_count: + schedule.append( + { + "after_slice_sequence": "1", + "intents": [ + { + "type": "submit_order", + "instrument_id": instruments[0]["instrument_id"], + "side": "buy", + "quantity": "1", + "order_kind": "limit", + "trigger_price": None, + "limit_price": "1", + "time_in_force": "gtc", + "venue_id": None, + "calendar_id": None, + "expires_at": None, + } + for _ in range(case.active_order_count) + ], + } + ) + + document.update( + { + "metadata": { + "producer": "trading-engine-benchmark", + "benchmark_case": case.name, + }, + "run_id": f"benchmark-{case.name}", + "initial_portfolio": { + "cash": [{"currency": "USD", "amount": "10000"}], + "positions": [], + "marks": [], + "fx_rates": [{"currency": "USD", "rate": "1"}], + }, + "instruments": instruments, + "venue_calendars": [ + { + "calendar_id": "benchmark-venue-calendar", + "calendar_version": "1", + "venue_id": "BENCHMARK", + "instrument_ids": [ + instrument["instrument_id"] for instrument in instruments + ], + "sessions": [ + { + "session_date": "2026-02-01", + "policy": "regular", + "phases": [ + { + "phase": "regular", + "opens_at": "2026-02-01T00:00:00Z", + "closes_at": "2026-02-02T00:00:00Z", + } + ], + } + ], + } + ], + "risk": { + "max_gross_exposure": "1000000000", + "max_leverage": "1000000", + "instrument_policies": [ + { + "instrument_id": instrument["instrument_id"], + "max_order_quantity": "1000000", + "max_long_position": "1000000", + "max_short_position": "1000000", + "max_notional_exposure": "1000000000", + "initial_margin_bps": 1, + "maintenance_margin_bps": 1, + "shorting_allowed": True, + } + for instrument in instruments + ], + "groups": [], + }, + "execution": { + "model": "completed_bar_v1", + "configuration": { + "version": "1", + "participation_bps": 10000, + "fee_schedules": [ + { + "schedule_id": f"{instrument['instrument_id']}-fees-v1", + "instrument_id": instrument["instrument_id"], + "settlement_currency": "USD", + "minimum": None, + "maximum": None, + "components": [ + { + "name": "benchmark", + "currency": "USD", + "kind": "fixed", + "value": "0", + "rounding": "nearest", + "applies_to": "any", + } + ], + } + for instrument in instruments + ], + }, + }, + "max_internal_events": max(1000, case.active_order_count * 4 + 16), + "schedule": schedule, + "slices": slices, + } + ) + return document + + +def stream_records(document: dict[str, object]) -> list[dict[str, object]]: + """Convert a batch document to the semantically equivalent stream records.""" + schedule = { + item["after_slice_sequence"]: item["intents"] + for item in document["schedule"] + } + header_fields = ( + "metadata", + "run_id", + "base_currency", + "initial_portfolio", + "instruments", + "venue_calendars", + "risk", + "execution", + "financing", + "settlement", + "max_internal_events", + ) + records = [ + { + "contract_version": document["contract_version"], + "scenario_sequence": "1", + "record_type": "scenario_header", + "payload": {field: document[field] for field in header_fields}, + } + ] + for index, market_slice in enumerate(document["slices"], start=2): + records.append( + { + "contract_version": document["contract_version"], + "scenario_sequence": str(index), + "record_type": "market_slice", + "payload": { + "market_slice": market_slice, + "intents": schedule.get(market_slice["slice_sequence"], []), + }, + } + ) + records.append( + { + "contract_version": document["contract_version"], + "scenario_sequence": str(len(records) + 1), + "record_type": "scenario_end", + "payload": {"slice_count": str(len(document["slices"]))}, + } + ) + return records + + +def write_input(case: BenchmarkCase, directory: Path) -> Path: + document = build_scenario(case) + if case.replay_format == "batch": + path = directory / "scenario.json" + path.write_text( + json.dumps(document, separators=(",", ":")) + "\n", encoding="utf-8" + ) + else: + path = directory / "scenario.jsonl" + with path.open("w", encoding="utf-8") as channel: + for record in stream_records(document): + channel.write(json.dumps(record, separators=(",", ":")) + "\n") + return path + + +def parse_summary(stdout: str) -> tuple[int, int]: + match = SUMMARY_PATTERN.search(stdout) + if match is None: + raise ValueError(f"could not parse replay summary: {stdout.strip()}") + return int(match.group("audits")), int(match.group("active")) + + +def _peak_rss_kib(usage: Any) -> int: + peak = int(usage.ru_maxrss) + return peak // 1024 if sys.platform == "darwin" else peak + + +def run_once( + executable: Path, case: BenchmarkCase, scenario: Path, directory: Path, run: int +) -> Sample: + journal = directory / f"journal-{run}.jsonl" + transcript = directory / f"strategy-{run}.jsonl" + command = [str(executable), "--input", str(scenario), "--journal", str(journal)] + if case.replay_format == "stream": + command.extend(("--input-format", "jsonl")) + if case.uses_external_strategy: + command.extend( + ( + "--strategy-executable", + sys.executable, + "--strategy-arg", + str(STRATEGY), + "--strategy-arg", + str(case.strategy_latency_ms), + "--strategy-timeout", + "30", + "--strategy-transcript", + str(transcript), + ) + ) + + environment = os.environ.copy() + environment["PYTHONDONTWRITEBYTECODE"] = "1" + with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file: + started = time.perf_counter_ns() + process = subprocess.Popen( + command, + cwd=ROOT, + env=environment, + stdout=stdout_file, + stderr=stderr_file, + ) + _, status, usage = os.wait4(process.pid, 0) + elapsed = (time.perf_counter_ns() - started) / 1_000_000_000 + process.returncode = os.waitstatus_to_exitcode(status) + stdout_file.seek(0) + stderr_file.seek(0) + stdout = stdout_file.read().decode("utf-8", errors="replace") + stderr = stderr_file.read().decode("utf-8", errors="replace") + if process.returncode != 0: + raise RuntimeError( + f"benchmark command failed with exit {process.returncode}: {stderr.strip()}" + ) + + audits, active = parse_summary(stdout) + if active != case.active_order_count: + raise RuntimeError( + f"{case.name} retained {active} active orders; expected " + f"{case.active_order_count}" + ) + artifact_paths = [journal] + if case.uses_external_strategy: + artifact_paths.append(transcript) + if any(not path.is_file() for path in artifact_paths): + raise RuntimeError(f"{case.name} did not publish every expected artifact") + journal_events = sum(1 for _ in journal.open("rb")) + if journal_events != audits: + raise RuntimeError( + f"{case.name} reported {audits} audits but wrote {journal_events} journal records" + ) + artifact_bytes = sum(path.stat().st_size for path in artifact_paths) + return Sample( + wall_seconds=elapsed, + peak_rss_kib=_peak_rss_kib(usage), + audit_events=audits, + artifact_bytes=artifact_bytes, + events_per_second=audits / elapsed, + artifact_bytes_per_second=artifact_bytes / elapsed, + ) + + +def summarize(case: BenchmarkCase, samples: list[Sample]) -> dict[str, object]: + if not samples: + raise ValueError("at least one sample is required") + audit_counts = {sample.audit_events for sample in samples} + artifact_sizes = {sample.artifact_bytes for sample in samples} + if len(audit_counts) != 1 or len(artifact_sizes) != 1: + raise RuntimeError(f"{case.name} produced nondeterministic artifacts") + return { + "case": asdict(case), + "audit_events": samples[0].audit_events, + "artifact_bytes": samples[0].artifact_bytes, + "median_wall_seconds": statistics.median( + sample.wall_seconds for sample in samples + ), + "median_peak_rss_kib": statistics.median( + sample.peak_rss_kib for sample in samples + ), + "median_events_per_second": statistics.median( + sample.events_per_second for sample in samples + ), + "median_artifact_bytes_per_second": statistics.median( + sample.artifact_bytes_per_second for sample in samples + ), + "samples": [asdict(sample) for sample in samples], + } + + +def find_regressions( + result: dict[str, object], baseline: dict[str, object] +) -> list[str]: + regressions = [] + tolerances = baseline["tolerances"] + metrics = baseline["metrics"] + for metric, direction in METRIC_DIRECTIONS.items(): + observed = float(result[metric]) + reference = float(metrics[metric]) + tolerance = float(tolerances[metric]) + threshold = reference * (1 + tolerance if direction == "higher" else 1 - tolerance) + regressed = observed > threshold if direction == "higher" else observed < threshold + if regressed: + regressions.append( + f"{metric}={observed:.3f} crossed advisory threshold {threshold:.3f}" + ) + return regressions + + +def benchmark_environment(executable: Path) -> dict[str, str]: + version = subprocess.run( + [str(executable), "--version"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + return { + "system": platform.system(), + "machine": platform.machine(), + "python": platform.python_version(), + "engine": version, + "dune_profile": os.environ.get("DUNE_PROFILE", "dev"), + } + + +def render(results: list[dict[str, object]]) -> None: + print( + "case | format | catalog | slices | active | latency ms | wall s | " + "peak MiB | events/s | artifact MiB/s | baseline" + ) + print("--- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---") + for result in results: + case = result["case"] + regressions = result.get("advisory_regressions", []) + latency = case["strategy_latency_ms"] + print( + f"{case['name']} | {case['replay_format']} | {case['catalog_size']} | " + f"{case['slice_count']} | {case['active_order_count']} | " + f"{'-' if latency is None else latency} | " + f"{result['median_wall_seconds']:.4f} | " + f"{result['median_peak_rss_kib'] / 1024:.1f} | " + f"{result['median_events_per_second']:.0f} | " + f"{result['median_artifact_bytes_per_second'] / 1048576:.2f} | " + f"{'advisory regression' if regressions else result.get('baseline_status', 'not compared')}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--executable", type=Path, default=DEFAULT_EXECUTABLE) + parser.add_argument("--suite", choices=("smoke", "full"), default="full") + parser.add_argument("--repetitions", type=int, default=3) + parser.add_argument("--warmups", type=int, default=1) + parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE) + parser.add_argument("--no-baseline", action="store_true") + parser.add_argument("--enforce", action="store_true") + parser.add_argument("--output", type=Path) + args = parser.parse_args() + if args.repetitions <= 0 or args.warmups < 0: + parser.error("repetitions must be positive and warmups cannot be negative") + if args.enforce and args.no_baseline: + parser.error("--enforce requires baseline comparison") + executable = args.executable.resolve() + if not executable.is_file(): + parser.error(f"executable does not exist: {executable}") + + baseline = None + if not args.no_baseline and args.baseline.is_file(): + baseline = json.loads(args.baseline.read_text(encoding="utf-8")) + if args.enforce and baseline is None: + parser.error(f"baseline does not exist: {args.baseline}") + cases = SMOKE_CASES if args.suite == "smoke" else FULL_CASES + results = [] + with tempfile.TemporaryDirectory(prefix="trading-engine-benchmark-") as raw_directory: + root = Path(raw_directory) + for case in cases: + case_directory = root / case.name + case_directory.mkdir() + scenario = write_input(case, case_directory) + for warmup in range(args.warmups): + run_once(executable, case, scenario, case_directory, -(warmup + 1)) + samples = [ + run_once(executable, case, scenario, case_directory, repetition) + for repetition in range(args.repetitions) + ] + result = summarize(case, samples) + baseline_case = None if baseline is None else baseline["cases"].get(case.name) + if baseline_case is None: + result["baseline_status"] = "not compared" + else: + result["baseline_status"] = "within tolerance" + result["advisory_regressions"] = find_regressions(result, baseline_case) + results.append(result) + + report = { + "schema_version": 1, + "generated_at": datetime.now(timezone.utc).isoformat(), + "suite": args.suite, + "repetitions": args.repetitions, + "warmups": args.warmups, + "environment": benchmark_environment(executable), + "results": results, + } + render(results) + if args.output is not None: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + print(f"wrote {args.output}") + regression_count = sum( + len(result.get("advisory_regressions", [])) for result in results + ) + if regression_count: + print( + f"{regression_count} advisory regression(s) detected; " + "use --enforce to make them fatal", + file=sys.stderr, + ) + return 1 if args.enforce and regression_count else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bench/latency_strategy.py b/bench/latency_strategy.py new file mode 100644 index 0000000..20396e2 --- /dev/null +++ b/bench/latency_strategy.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""No-op strategy that adds deterministic per-event response latency.""" + +from __future__ import annotations + +import json +import sys +import time + + +LATENCY_SECONDS = float(sys.argv[1]) / 1000 if len(sys.argv) > 1 else 0.0 +if LATENCY_SECONDS < 0: + raise ValueError("latency must not be negative") + + +for line in sys.stdin: + request = json.loads(line) + message_type = request["message_type"] + if message_type == "initialize": + response_type = "ready" + payload = { + "strategy_name": "benchmark-latency", + "strategy_version": "1", + } + elif message_type == "event": + if LATENCY_SECONDS: + time.sleep(LATENCY_SECONDS) + response_type = "intents" + payload = {"intents": []} + elif message_type == "shutdown": + response_type = "stopped" + payload = {} + else: + response_type = "error" + payload = {"message": "unsupported request"} + print( + json.dumps( + { + "strategy_protocol_version": request["strategy_protocol_version"], + "strategy_sequence": request["strategy_sequence"], + "message_type": response_type, + "payload": payload, + }, + separators=(",", ":"), + ), + flush=True, + ) + if message_type == "shutdown": + break diff --git a/bin/dune b/bin/dune index 28dcdff..d1392d9 100644 --- a/bin/dune +++ b/bin/dune @@ -2,4 +2,14 @@ (name main) (public_name trading-engine) (package trading_engine) - (libraries trading_engine cmdliner eio_main fmt.tty logs.fmt logs.cli)) + (instrumentation + (backend bisect_ppx)) + (libraries + trading_engine + cmdliner + eio_main + fmt.tty + logs.fmt + logs.cli + yojson + unix)) diff --git a/bin/main.ml b/bin/main.ml index fdbc957..53f83b7 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -1,66 +1,289 @@ open Cmdliner +type diagnostic_format = Human | Json +type output_format = Human_output | Json_output + +type counts = { + instruments : int64; + schedule_batches : int64; + slices : int64; + audits : int64; + orders : int64; + active_orders : int64; + filled_orders : int64; + rejected_orders : int64; +} + +type success = { + operation : string; + run_id : Trading_engine.Id.Run.t; + scenario_sha256 : string; + journal_sha256 : string option; + transcript_sha256 : string option; + counts : counts; + valuation : Trading_engine.Account.valuation; + journal : string option; + transcript : string option; +} + +type journal_destination = { + replay_path : string; + public_path : string; + writes_stdout : bool; + cleanup : unit -> unit; +} + +let cli_error message = + Trading_engine.Diagnostic.make + ~code:Trading_engine.Diagnostic.Cli_invalid_arguments + ~phase:Trading_engine.Diagnostic.Cli message + +let input_error ~message exception_ = + Trading_engine.Diagnostic.of_exception + ~code:Trading_engine.Diagnostic.Input_io + ~phase:Trading_engine.Diagnostic.Input ~message exception_ + let count predicate values = List.fold_left (fun total value -> total + Bool.to_int (predicate value)) 0 values -let run_replay scenario_sha256 scenario journal = +let int64 value = `Intlit (Int64.to_string value) +let option_string = function None -> `Null | Some value -> `String value + +let success_to_yojson success = + let counts = success.counts in + `Assoc + [ + ("result_version", `String "1"); + ("status", `String "success"); + ("operation", `String success.operation); + ("run_id", `String (Trading_engine.Id.Run.to_string success.run_id)); + ( "hashes", + `Assoc + [ + ("scenario_sha256", `String success.scenario_sha256); + ("journal_sha256", option_string success.journal_sha256); + ( "strategy_transcript_sha256", + option_string success.transcript_sha256 ); + ] ); + ( "counts", + `Assoc + [ + ("instruments", int64 counts.instruments); + ("schedule_batches", int64 counts.schedule_batches); + ("slices", int64 counts.slices); + ("audits", int64 counts.audits); + ("orders", int64 counts.orders); + ("active_orders", int64 counts.active_orders); + ("filled_orders", int64 counts.filled_orders); + ("rejected_orders", int64 counts.rejected_orders); + ] ); + ( "valuation", + Trading_engine.Codec.account_valuation_to_yojson success.valuation ); + ( "artifacts", + `Assoc + [ + ("journal", option_string success.journal); + ("strategy_transcript", option_string success.transcript); + ] ); + ] + +let order_counts orders = + let active = count Trading_engine.Order.is_active orders in + let filled = + count + (fun order -> + order.Trading_engine.Order.status = Trading_engine.Order.Filled) + orders + in + let rejected = + count + (fun order -> + match order.Trading_engine.Order.status with + | Trading_engine.Order.Rejected _ -> true + | _ -> false) + orders + in + (active, filled, rejected) + +let emit_success format ~to_stderr success = + let formatter = + if to_stderr then Format.err_formatter else Format.std_formatter + in + match format with + | Json_output -> + Fmt.pf formatter "%s@." + (Yojson.Safe.to_string (success_to_yojson success)) + | Human_output -> + let counts = success.counts in + Fmt.pf formatter + "run=%a audits=%Ld orders=%Ld active=%Ld filled=%Ld rejected=%Ld@." + Trading_engine.Id.Run.pp success.run_id counts.audits counts.orders + counts.active_orders counts.filled_orders counts.rejected_orders; + Fmt.pf formatter "%a@." Trading_engine.Account.pp_valuation + success.valuation; + Option.iter (Fmt.pf formatter "journal=%s@.") success.journal; + Option.iter + (Fmt.pf formatter "strategy_transcript=%s@.") + success.transcript + +let digest_file path = Trading_engine.Sha256.digest_file path + +let remove_if_exists path = + try if Sys.file_exists path then Sys.remove path with Sys_error _ -> () + +let temporary_journal_destination () = + try + let directory = + Filename.temp_dir ~perms:0o700 "trading-engine-journal-" "" + in + let path = Filename.concat directory "journal.jsonl" in + Ok + { + replay_path = path; + public_path = "stdout"; + writes_stdout = true; + cleanup = + (fun () -> + remove_if_exists path; + remove_if_exists (path ^ ".partial"); + remove_if_exists (path ^ ".partial.cleanup"); + try Unix.rmdir directory with Unix.Unix_error _ -> ()); + } + with exception_ -> + Error + (Trading_engine.Diagnostic.of_exception + ~code:Trading_engine.Diagnostic.Artifact_io + ~phase:Trading_engine.Diagnostic.Artifact + ~message:"could not create temporary journal spool" exception_) + +let journal_destination path = + if String.equal path "-" then temporary_journal_destination () + else + Ok + { + replay_path = path; + public_path = path; + writes_stdout = false; + cleanup = Fun.id; + } + +let copy_file_to_stdout path = + try + In_channel.with_open_bin path (fun channel -> + let buffer = Bytes.create 65_536 in + let rec loop () = + match input channel buffer 0 (Bytes.length buffer) with + | 0 -> () + | length -> + output stdout buffer 0 length; + loop () + in + loop ()); + flush stdout; + Ok () + with exception_ -> + Error + (Trading_engine.Diagnostic.of_exception + ~code:Trading_engine.Diagnostic.Artifact_io + ~phase:Trading_engine.Diagnostic.Artifact + ~message:"could not write journal to standard output" exception_) + +let finish_success output_format destination success = + let result = + match digest_file destination.replay_path with + | Error _ as error -> error + | Ok journal_sha256 -> + let success = + { + success with + journal_sha256 = Some journal_sha256; + journal = Some destination.public_path; + } + in + if destination.writes_stdout then ( + match copy_file_to_stdout destination.replay_path with + | Error _ as error -> error + | Ok () -> + emit_success output_format ~to_stderr:true success; + Ok ()) + else ( + emit_success output_format ~to_stderr:false success; + Ok ()) + in + destination.cleanup (); + result + +let run_replay scenario_sha256 scenario destination durability output_format = match - Trading_engine.Replay.run ~scenario_sha256 ~journal_path:journal scenario + Trading_engine.Replay.run ~scenario_sha256 + ~journal_path:destination.replay_path ~durability scenario with - | Error message -> Error message + | Error message -> + destination.cleanup (); + Error message | Ok result -> - let active = count Trading_engine.Order.is_active result.orders in - let filled = - count - (fun order -> - order.Trading_engine.Order.status = Trading_engine.Order.Filled) - result.orders - in - let rejected = - count - (fun order -> - match order.Trading_engine.Order.status with - | Trading_engine.Order.Rejected _ -> true - | _ -> false) - result.orders + let active, filled, rejected = order_counts result.orders in + let success = + { + operation = "replay"; + run_id = scenario.Trading_engine.Scenario.run_id; + scenario_sha256; + journal_sha256 = None; + transcript_sha256 = None; + counts = + { + instruments = Int64.of_int (List.length scenario.instruments); + schedule_batches = Int64.of_int (List.length scenario.schedule); + slices = Int64.of_int (List.length scenario.slices); + audits = Int64.of_int (List.length result.audits); + orders = Int64.of_int (List.length result.orders); + active_orders = Int64.of_int active; + filled_orders = Int64.of_int filled; + rejected_orders = Int64.of_int rejected; + }; + valuation = result.valuation; + journal = None; + transcript = None; + } in - Fmt.pr "run=%a audits=%d orders=%d active=%d filled=%d rejected=%d@." - Trading_engine.Id.Run.pp scenario.Trading_engine.Scenario.run_id - (List.length result.audits) - (List.length result.orders) - active filled rejected; - Fmt.pr "%a@." Trading_engine.Account.pp_valuation result.valuation; - Fmt.pr "journal=%s@." journal; - Ok () - -let run_stream input journal = - match Trading_engine.Replay.run_stream ~journal_path:journal input with - | Error message -> Error message + finish_success output_format destination success + +let run_stream input destination durability output_format = + match + Trading_engine.Replay.run_stream ~journal_path:destination.replay_path + ~durability input + with + | Error message -> + destination.cleanup (); + Error message | Ok result -> - let active = count Trading_engine.Order.is_active result.orders in - let filled = - count - (fun order -> - order.Trading_engine.Order.status = Trading_engine.Order.Filled) - result.orders - in - let rejected = - count - (fun order -> - match order.Trading_engine.Order.status with - | Trading_engine.Order.Rejected _ -> true - | _ -> false) - result.orders + let active, filled, rejected = order_counts result.orders in + let success = + { + operation = "replay"; + run_id = result.run_id; + scenario_sha256 = result.scenario_sha256; + journal_sha256 = None; + transcript_sha256 = None; + counts = + { + instruments = Int64.of_int result.instrument_count; + schedule_batches = result.schedule_count; + slices = result.slice_count; + audits = result.audit_count; + orders = Int64.of_int (List.length result.orders); + active_orders = Int64.of_int active; + filled_orders = Int64.of_int filled; + rejected_orders = Int64.of_int rejected; + }; + valuation = result.valuation; + journal = None; + transcript = None; + } in - Fmt.pr "run=%a audits=%Ld orders=%d active=%d filled=%d rejected=%d@." - Trading_engine.Id.Run.pp result.run_id result.audit_count - (List.length result.orders) - active filled rejected; - Fmt.pr "%a@." Trading_engine.Account.pp_valuation result.valuation; - Fmt.pr "journal=%s@." journal; - Ok () + finish_success output_format destination success type external_strategy = { command : string list; @@ -68,77 +291,124 @@ type external_strategy = { transcript : string; } -let run_external_replay environment scenario_sha256 scenario journal strategy = +let run_external_replay environment scenario_sha256 scenario destination + strategy durability output_format = match - Trading_engine.External_replay.run ~env:environment ~scenario_sha256 - ~journal_path:journal ~transcript_path:strategy.transcript - ~strategy_command:strategy.command ~strategy_timeout:strategy.timeout - scenario + Trading_engine.External_replay.run ~durability ~env:environment + ~scenario_sha256 ~journal_path:destination.replay_path + ~transcript_path:strategy.transcript ~strategy_command:strategy.command + ~strategy_timeout:strategy.timeout scenario with - | Error message -> Error message - | Ok result -> - let active = count Trading_engine.Order.is_active result.orders in - let filled = - count - (fun order -> - order.Trading_engine.Order.status = Trading_engine.Order.Filled) - result.orders - in - let rejected = - count - (fun order -> - match order.Trading_engine.Order.status with - | Trading_engine.Order.Rejected _ -> true - | _ -> false) - result.orders - in - Fmt.pr "run=%a audits=%d orders=%d active=%d filled=%d rejected=%d@." - Trading_engine.Id.Run.pp scenario.Trading_engine.Scenario.run_id - (List.length result.audits) - (List.length result.orders) - active filled rejected; - Fmt.pr "%a@." Trading_engine.Account.pp_valuation result.valuation; - Fmt.pr "journal=%s@." journal; - Fmt.pr "strategy_transcript=%s@." strategy.transcript; - Ok () - -let run_external_stream environment input journal strategy = + | Error message -> + destination.cleanup (); + Error message + | Ok result -> ( + let active, filled, rejected = order_counts result.orders in + match digest_file strategy.transcript with + | Error _ as error -> + destination.cleanup (); + error + | Ok transcript_sha256 -> + finish_success output_format destination + { + operation = "replay"; + run_id = scenario.Trading_engine.Scenario.run_id; + scenario_sha256; + journal_sha256 = None; + transcript_sha256 = Some transcript_sha256; + counts = + { + instruments = Int64.of_int (List.length scenario.instruments); + schedule_batches = 0L; + slices = Int64.of_int (List.length scenario.slices); + audits = Int64.of_int (List.length result.audits); + orders = Int64.of_int (List.length result.orders); + active_orders = Int64.of_int active; + filled_orders = Int64.of_int filled; + rejected_orders = Int64.of_int rejected; + }; + valuation = result.valuation; + journal = None; + transcript = Some strategy.transcript; + }) + +let run_external_stream environment input destination strategy durability + output_format = match - Trading_engine.External_replay.run_stream ~env:environment - ~journal_path:journal ~transcript_path:strategy.transcript + Trading_engine.External_replay.run_stream ~durability ~env:environment + ~journal_path:destination.replay_path ~transcript_path:strategy.transcript ~strategy_command:strategy.command ~strategy_timeout:strategy.timeout input with - | Error message -> Error message - | Ok result -> - let active = count Trading_engine.Order.is_active result.orders in - let filled = - count - (fun order -> - order.Trading_engine.Order.status = Trading_engine.Order.Filled) - result.orders - in - let rejected = - count - (fun order -> - match order.Trading_engine.Order.status with - | Trading_engine.Order.Rejected _ -> true - | _ -> false) - result.orders - in - Fmt.pr "run=%a audits=%Ld orders=%d active=%d filled=%d rejected=%d@." - Trading_engine.Id.Run.pp result.run_id result.audit_count - (List.length result.orders) - active filled rejected; - Fmt.pr "%a@." Trading_engine.Account.pp_valuation result.valuation; - Fmt.pr "journal=%s@." journal; - Fmt.pr "strategy_transcript=%s@." strategy.transcript; - Ok () - -let execute_json environment input journal validate_only strategy = + | Error message -> + destination.cleanup (); + Error message + | Ok result -> ( + let active, filled, rejected = order_counts result.orders in + match digest_file strategy.transcript with + | Error _ as error -> + destination.cleanup (); + error + | Ok transcript_sha256 -> + finish_success output_format destination + { + operation = "replay"; + run_id = result.run_id; + scenario_sha256 = result.scenario_sha256; + journal_sha256 = None; + transcript_sha256 = Some transcript_sha256; + counts = + { + instruments = Int64.of_int result.instrument_count; + schedule_batches = 0L; + slices = result.slice_count; + audits = result.audit_count; + orders = Int64.of_int (List.length result.orders); + active_orders = Int64.of_int active; + filled_orders = Int64.of_int filled; + rejected_orders = Int64.of_int rejected; + }; + valuation = result.valuation; + journal = None; + transcript = Some strategy.transcript; + }) + +let emit_validation output_format ~run_id ~scenario_sha256 ~instrument_count + ~schedule_count ~slice_count ~orders ~valuation ~audit_count = + let active, filled, rejected = order_counts orders in + emit_success output_format ~to_stderr:false + { + operation = "validate"; + run_id; + scenario_sha256; + journal_sha256 = None; + transcript_sha256 = None; + counts = + { + instruments = instrument_count; + schedule_batches = schedule_count; + slices = slice_count; + audits = audit_count; + orders = Int64.of_int (List.length orders); + active_orders = Int64.of_int active; + filled_orders = Int64.of_int filled; + rejected_orders = Int64.of_int rejected; + }; + valuation; + journal = None; + transcript = None; + }; + Ok () + +let execute_json environment input journal validate_only strategy durability + output_format = let document = try Ok (In_channel.with_open_bin input In_channel.input_all) - with Sys_error message -> Error ("could not read scenario: " ^ message) + with Sys_error message as exception_ -> + Error + (input_error + ~message:("could not read scenario: " ^ message) + exception_) in match document with | Error _ as error -> error @@ -149,77 +419,191 @@ let execute_json environment input journal validate_only strategy = | Ok scenario -> ( if validate_only then match journal with - | Some _ -> Error "--journal cannot be used with --validate-only" + | Some _ -> + Error + (cli_error "--journal cannot be used with --validate-only") | None -> ( match Trading_engine.Replay.run ~scenario_sha256 scenario with | Error message -> Error message - | Ok _ -> - Fmt.pr - "valid run=%a instruments=%d schedule=%d slices=%d \ - scenario_sha256=%s@." - Trading_engine.Id.Run.pp scenario.run_id - (List.length scenario.instruments) - (List.length scenario.schedule) - (List.length scenario.slices) - scenario_sha256; - Ok ()) + | Ok result -> + if output_format = Human_output then ( + Fmt.pr + "valid run=%a instruments=%d schedule=%d slices=%d \ + scenario_sha256=%s@." + Trading_engine.Id.Run.pp scenario.run_id + (List.length scenario.instruments) + (List.length scenario.schedule) + (List.length scenario.slices) + scenario_sha256; + Ok ()) + else + emit_validation output_format ~run_id:scenario.run_id + ~scenario_sha256 + ~instrument_count: + (Int64.of_int (List.length scenario.instruments)) + ~schedule_count: + (Int64.of_int (List.length scenario.schedule)) + ~slice_count: + (Int64.of_int (List.length scenario.slices)) + ~orders:result.orders ~valuation:result.valuation + ~audit_count:(Int64.of_int (List.length result.audits))) else match journal with | None -> - Error "--journal is required unless --validate-only is set" + Error + (cli_error + "--journal is required unless --validate-only is set") | Some path -> ( - match strategy with - | None -> run_replay scenario_sha256 scenario path - | Some strategy -> - run_external_replay environment scenario_sha256 scenario - path strategy))) + match journal_destination path with + | Error _ as error -> error + | Ok destination -> ( + match strategy with + | None -> + run_replay scenario_sha256 scenario destination + durability output_format + | Some strategy -> + run_external_replay environment scenario_sha256 scenario + destination strategy durability output_format)))) -let execute_jsonl environment input journal validate_only strategy = +let execute_jsonl environment input journal validate_only strategy durability + output_format = if validate_only then match journal with - | Some _ -> Error "--journal cannot be used with --validate-only" + | Some _ -> + Error (cli_error "--journal cannot be used with --validate-only") | None -> ( match Trading_engine.Replay.run_stream input with | Error message -> Error message | Ok result -> - Fmt.pr - "valid run=%a instruments=%d schedule=%Ld slices=%Ld \ - scenario_sha256=%s@." - Trading_engine.Id.Run.pp result.run_id result.instrument_count - result.schedule_count result.slice_count result.scenario_sha256; - Ok ()) + if output_format = Human_output then ( + Fmt.pr + "valid run=%a instruments=%d schedule=%Ld slices=%Ld \ + scenario_sha256=%s@." + Trading_engine.Id.Run.pp result.run_id result.instrument_count + result.schedule_count result.slice_count result.scenario_sha256; + Ok ()) + else + emit_validation output_format ~run_id:result.run_id + ~scenario_sha256:result.scenario_sha256 + ~instrument_count:(Int64.of_int result.instrument_count) + ~schedule_count:result.schedule_count + ~slice_count:result.slice_count ~orders:result.orders + ~valuation:result.valuation ~audit_count:result.audit_count) else match journal with - | None -> Error "--journal is required unless --validate-only is set" + | None -> + Error (cli_error "--journal is required unless --validate-only is set") | Some path -> ( - match strategy with - | None -> run_stream input path - | Some strategy -> run_external_stream environment input path strategy) + match journal_destination path with + | Error _ as error -> error + | Ok destination -> ( + match strategy with + | None -> run_stream input destination durability output_format + | Some strategy -> + run_external_stream environment input destination strategy + durability output_format)) type input_format = Json | Jsonl -let execute_scenario environment input journal validate_only strategy = function - | Json -> execute_json environment input journal validate_only strategy - | Jsonl -> execute_jsonl environment input journal validate_only strategy +let execute_scenario environment input journal validate_only strategy durability + output_format = function + | Json -> + execute_json environment input journal validate_only strategy durability + output_format + | Jsonl -> + execute_jsonl environment input journal validate_only strategy durability + output_format let external_strategy executable arguments timeout transcript = match (executable, transcript, arguments, timeout) with | None, None, [], None -> Ok None | None, _, _, _ -> Error - "--strategy-arg, --strategy-timeout, and --strategy-transcript require \ - --strategy-executable" + (cli_error + "--strategy-arg, --strategy-timeout, and --strategy-transcript \ + require --strategy-executable") | Some _, None, _, _ -> - Error "--strategy-transcript is required with --strategy-executable" + Error + (cli_error + "--strategy-transcript is required with --strategy-executable") | Some executable, Some transcript, arguments, timeout -> let timeout = Option.value timeout ~default:30.0 in if (not (Float.is_finite timeout)) || Float.compare timeout 0.0 <= 0 then - Error "--strategy-timeout must be finite and positive" + Error (cli_error "--strategy-timeout must be finite and positive") else Ok (Some { command = executable :: arguments; timeout; transcript }) +let spool_standard_input () = + let path, channel = + Filename.open_temp_file ~mode:[ Open_binary ] "trading-engine-stdin-" + ".jsonl" + in + let fail diagnostic = + close_out_noerr channel; + remove_if_exists path; + Error diagnostic + in + try + let buffer = Bytes.create 65_536 in + let rec loop total = + match input stdin buffer 0 (Bytes.length buffer) with + | 0 -> Ok total + | length -> + if + total + > Trading_engine.Resource_limits.scenario_stream_bytes - length + then + Error + (Trading_engine.Diagnostic.make + ~code:Trading_engine.Diagnostic.Resource_limit + ~phase:Trading_engine.Diagnostic.Input + (Printf.sprintf + "standard-input scenario stream exceeds %d bytes" + Trading_engine.Resource_limits.scenario_stream_bytes)) + else ( + output channel buffer 0 length; + loop (total + length)) + in + match loop 0 with + | Error diagnostic -> fail diagnostic + | Ok _ -> + close_out channel; + Ok path + with exception_ -> + fail + (Trading_engine.Diagnostic.of_exception + ~code:Trading_engine.Diagnostic.Input_io + ~phase:Trading_engine.Diagnostic.Input + ~message:"could not spool scenario stream from standard input" + exception_) + +let with_input_path input input_format function_ = + if not (String.equal input "-") then function_ input + else + match input_format with + | Json -> + Error + (cli_error + "standard input requires --input-format jsonl; batch JSON is not \ + supported") + | Jsonl -> ( + try + match spool_standard_input () with + | Error _ as error -> error + | Ok path -> + Fun.protect + ~finally:(fun () -> remove_if_exists path) + (fun () -> function_ path) + with exception_ -> + Error + (Trading_engine.Diagnostic.of_exception + ~code:Trading_engine.Diagnostic.Input_io + ~phase:Trading_engine.Diagnostic.Input + ~message:"could not prepare standard-input scenario stream" + exception_)) + let execute environment input journal validate_only capabilities input_format strategy_executable strategy_arguments strategy_timeout strategy_transcript - = + durable_artifacts output_format = if capabilities then match ( input, @@ -228,17 +612,32 @@ let execute environment input journal validate_only capabilities input_format strategy_executable, strategy_arguments, strategy_timeout, - strategy_transcript ) + strategy_transcript, + durable_artifacts ) with - | None, None, false, None, [], None, None -> + | None, None, false, None, [], None, None, false -> Fmt.pr "%s@." (Trading_engine.Contract.capabilities_to_string ()); Ok () | _ -> Error - "--capabilities cannot be combined with replay or strategy options" + (cli_error + "--capabilities cannot be combined with replay or strategy options") + else if validate_only && durable_artifacts then + Error (cli_error "--durable-artifacts cannot be used with --validate-only") + else if durable_artifacts && Option.equal String.equal journal (Some "-") then + Error + (cli_error + "--durable-artifacts cannot be used when --journal writes to standard \ + output") + else if Option.equal String.equal strategy_transcript (Some "-") then + Error + (cli_error + "--strategy-transcript does not support standard output; choose a \ + file path") else match input with - | None -> Error "--input is required unless --capabilities is set" + | None -> + Error (cli_error "--input is required unless --capabilities is set") | Some path -> ( match external_strategy strategy_executable strategy_arguments @@ -247,15 +646,23 @@ let execute environment input journal validate_only capabilities input_format | Error _ as error -> error | Ok (Some _) when validate_only -> Error - "external strategy options cannot be used with --validate-only" + (cli_error + "external strategy options cannot be used with --validate-only") | Ok strategy -> - execute_scenario environment path journal validate_only strategy - input_format) + let durability = + if durable_artifacts then Trading_engine.Artifact_writer.Durable + else Trading_engine.Artifact_writer.Buffered + in + with_input_path path input_format (fun input_path -> + execute_scenario environment input_path journal validate_only + strategy durability output_format input_format)) let input = let doc = "Read the replay scenario from $(docv)." in Arg.( - value & opt (some file) None & info [ "input"; "i" ] ~docv:"SCENARIO" ~doc) + value + & opt (some string) None + & info [ "input"; "i" ] ~docv:"SCENARIO|-" ~doc) let input_format = let formats = Arg.enum [ ("json", Json); ("jsonl", Jsonl) ] in @@ -263,12 +670,22 @@ let input_format = Arg.(value & opt formats Json & info [ "input-format" ] ~docv:"FORMAT" ~doc) let journal = - let doc = "Create the append-only JSON Lines audit journal at $(docv)." in + let doc = + "Create the append-only JSON Lines audit journal at $(docv). Use '-' to \ + write a completed journal to standard output." + in Arg.( value & opt (some string) None & info [ "journal"; "j" ] ~docv:"JOURNAL.jsonl" ~doc) +let durable_artifacts = + let doc = + "Synchronize staged artifact contents and directory metadata before \ + reporting success." + in + Arg.(value & flag & info [ "durable-artifacts" ] ~doc) + let validate_only = let doc = "Validate the scenario and exit without creating a journal." in Arg.(value & flag & info [ "validate-only" ] ~doc) @@ -277,6 +694,22 @@ let capabilities = let doc = "Print machine-readable engine capabilities as JSON and exit." in Arg.(value & flag & info [ "capabilities" ] ~doc) +let diagnostic_format = + let formats = Arg.enum [ ("human", Human); ("json", Json) ] in + let doc = "Render runtime diagnostics as $(docv) (default: human)." in + Arg.( + value & opt formats Human & info [ "diagnostic-format" ] ~docv:"FORMAT" ~doc) + +let output_format = + let formats = Arg.enum [ ("human", Human_output); ("json", Json_output) ] in + let doc = + "Render successful validation and replay summaries as $(docv) (default: \ + human). JSON output also selects JSON diagnostics." + in + Arg.( + value & opt formats Human_output + & info [ "output-format" ] ~docv:"FORMAT" ~doc) + let strategy_executable = let doc = "Launch $(docv) as the external strategy process without using a shell." @@ -329,12 +762,45 @@ let command environment = (Cmd.info "trading-engine" ~version:Trading_engine.Contract.engine_version ~doc ~man) Term.( - const (execute environment) + const + (fun + input + journal + validate_only + capabilities + input_format + strategy_executable + strategy_arguments + strategy_timeout + strategy_transcript + durable_artifacts + diagnostic_format + output_format + -> + ( diagnostic_format, + output_format, + execute environment input journal validate_only capabilities + input_format strategy_executable strategy_arguments + strategy_timeout strategy_transcript durable_artifacts + output_format )) $ input $ journal $ validate_only $ capabilities $ input_format $ strategy_executable $ strategy_argument $ strategy_timeout - $ strategy_transcript) + $ strategy_transcript $ durable_artifacts $ diagnostic_format + $ output_format) let () = Fmt_tty.setup_std_outputs (); Eio_main.run @@ fun environment -> - exit (Cmd.eval_result (command environment)) + match Cmd.eval_value' (command environment) with + | `Exit code -> exit code + | `Ok (_, _, Ok ()) -> exit Cmd.Exit.ok + | `Ok (diagnostic_format, output_format, Error diagnostic) -> + let rendered = + match (diagnostic_format, output_format) with + | Human, Human_output -> + "trading-engine: " ^ Trading_engine.Diagnostic.to_human diagnostic + | Json, _ | _, Json_output -> + Trading_engine.Diagnostic.to_json diagnostic + in + Fmt.epr "%s@." rendered; + exit Cmd.Exit.some_error diff --git a/contracts/cli/v1/README.md b/contracts/cli/v1/README.md new file mode 100644 index 0000000..110df86 --- /dev/null +++ b/contracts/cli/v1/README.md @@ -0,0 +1,18 @@ +# CLI result contract v1 + +Pass `--output-format json` to receive one compact JSON success document. The +[`result.schema.json`](result.schema.json) schema defines its stable fields. The document identifies +the operation and run, binds scenario and artifact hashes, reports replay counts and the normalized +current valuation, and names any created artifacts. + +Failures use the existing +[diagnostic contract v1](../../diagnostic/v1/diagnostic.schema.json). Selecting JSON output also +selects JSON diagnostics, so automation does not need to combine two format flags. Diagnostics are +always written to standard error. + +When `--journal -` is selected, standard output contains only the complete JSON Lines journal. The +success document moves to standard error. Its journal artifact is named `stdout`, and the final +`run_completed` record signals successful completion. A consumer must also require a zero process +exit status. Strategy protocol messages remain confined to the supervised child process. + +The result contract reports the normalized replay contract v1 valuation. diff --git a/contracts/cli/v1/dune b/contracts/cli/v1/dune new file mode 100644 index 0000000..c85ad1c --- /dev/null +++ b/contracts/cli/v1/dune @@ -0,0 +1,7 @@ +(install + (section share) + (package trading_engine) + (files + (README.md as contracts/cli/v1/README.md) + (result.schema.json as contracts/cli/v1/result.schema.json) + (fixtures/demo.result.json as contracts/cli/v1/fixtures/demo.result.json))) diff --git a/contracts/cli/v1/fixtures/demo.result.json b/contracts/cli/v1/fixtures/demo.result.json new file mode 100644 index 0000000..efb13c6 --- /dev/null +++ b/contracts/cli/v1/fixtures/demo.result.json @@ -0,0 +1 @@ +{"result_version":"1","status":"success","operation":"validate","run_id":"demo","hashes":{"scenario_sha256":"a56b9b38f18d93e2953f90c8b052026d174e91c01a9465f8070a22040820a78d","journal_sha256":null,"strategy_transcript_sha256":null},"counts":{"instruments":1,"schedule_batches":2,"slices":4,"audits":29,"orders":3,"active_orders":0,"filled_orders":2,"rejected_orders":0},"valuation":{"base_currency":"USD","cash":"9846.979929","net_market_value":"265","long_market_value":"265","short_market_value":"0","gross_exposure":"265","cost_basis":"257.154644","realized_pnl":"19.134573","unrealized_pnl":"7.845356","equity":"10111.979929","dividend_pnl":"1","execution_fees":"2.907896","borrow_fees":"0.25","total_fees":"3.157896","cash_balances":[{"currency":"USD","amount":"9846.979929","fx_rate":"1","base_value":"9846.979929","interest":"0.285075","base_interest":"0.285075","settled_amount":"9846.979929","unsettled_amount":"0","base_settled_value":"9846.979929","base_unsettled_value":"0"}],"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.154644","base_cost_basis":"257.154644","realized_pnl":"18.849498","base_realized_pnl":"18.849498","unrealized_pnl":"7.845356","base_unrealized_pnl":"7.845356","dividend_pnl":"1","base_dividend_pnl":"1","execution_fees":"2.907896","base_execution_fees":"2.907896","borrow_fees":"0.25","base_borrow_fees":"0.25","total_fees":"3.157896","base_total_fees":"3.157896","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.664894","quote_currency":"USD","quote_amount":"1.664894","base_amount":"1.664894"},{"name":"maximum_adjustment","kind":"maximum_adjustment","currency":"USD","amount":"-0.006998","quote_currency":"USD","quote_amount":"-0.006998","base_amount":"-0.006998"}],"settled_quantity":"2.5","unsettled_quantity":"0"}],"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.664894","quote_currency":"USD","quote_amount":"1.664894","base_amount":"1.664894"},{"name":"maximum_adjustment","kind":"maximum_adjustment","currency":"USD","amount":"-0.006998","quote_currency":"USD","quote_amount":"-0.006998","base_amount":"-0.006998"}],"cash_interest":"0.285075","settled_cash":"9846.979929","unsettled_cash":"0"},"artifacts":{"journal":null,"strategy_transcript":null}} diff --git a/contracts/cli/v1/result.schema.json b/contracts/cli/v1/result.schema.json new file mode 100644 index 0000000..504489c --- /dev/null +++ b/contracts/cli/v1/result.schema.json @@ -0,0 +1,130 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/fallblu/trading-engine/contracts/cli/v1/result.schema.json", + "title": "Trading Engine CLI success result v1", + "type": "object", + "additionalProperties": false, + "required": [ + "result_version", + "status", + "operation", + "run_id", + "hashes", + "counts", + "valuation", + "artifacts" + ], + "properties": { + "result_version": { "const": "1" }, + "status": { "const": "success" }, + "operation": { "enum": ["validate", "replay"] }, + "run_id": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/identifier" }, + "hashes": { + "type": "object", + "additionalProperties": false, + "required": ["scenario_sha256", "journal_sha256", "strategy_transcript_sha256"], + "properties": { + "scenario_sha256": { "$ref": "#/$defs/sha256" }, + "journal_sha256": { "oneOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] }, + "strategy_transcript_sha256": { "oneOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] } + } + }, + "counts": { + "type": "object", + "additionalProperties": false, + "required": ["instruments", "schedule_batches", "slices", "audits", "orders", "active_orders", "filled_orders", "rejected_orders"], + "properties": { + "instruments": { "$ref": "#/$defs/count" }, + "schedule_batches": { "$ref": "#/$defs/count" }, + "slices": { "$ref": "#/$defs/count" }, + "audits": { "$ref": "#/$defs/count" }, + "orders": { "$ref": "#/$defs/count" }, + "active_orders": { "$ref": "#/$defs/count" }, + "filled_orders": { "$ref": "#/$defs/count" }, + "rejected_orders": { "$ref": "#/$defs/count" } + } + }, + "valuation": { "$ref": "#/$defs/accountValuation" }, + "artifacts": { + "type": "object", + "additionalProperties": false, + "required": ["journal", "strategy_transcript"], + "properties": { + "journal": { "oneOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] }, + "strategy_transcript": { "oneOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] } + } + } + }, + "allOf": [ + { + "if": { "properties": { "operation": { "const": "validate" } } }, + "then": { + "properties": { + "hashes": { "properties": { "journal_sha256": { "type": "null" }, "strategy_transcript_sha256": { "type": "null" } } }, + "artifacts": { "properties": { "journal": { "type": "null" }, "strategy_transcript": { "type": "null" } } } + } + } + }, + { + "if": { "properties": { "operation": { "const": "replay" } } }, + "then": { + "properties": { + "hashes": { "properties": { "journal_sha256": { "$ref": "#/$defs/sha256" } } }, + "artifacts": { "properties": { "journal": { "type": "string", "minLength": 1 } } } + } + } + } + ], + "$defs": { + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "count": { "type": "integer", "minimum": 0 }, + "accountValuation": { + "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", + "execution_fee_components" + ], + "properties": { + "base_currency": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/identifier" }, + "cash": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/signedDecimal" }, + "settled_cash": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/signedDecimal" }, + "unsettled_cash": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/signedDecimal" }, + "net_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/signedDecimal" }, + "long_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/unsignedDecimal" }, + "short_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/unsignedDecimal" }, + "gross_exposure": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/unsignedDecimal" }, + "cost_basis": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/signedDecimal" }, + "realized_pnl": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/signedDecimal" }, + "unrealized_pnl": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/signedDecimal" }, + "equity": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/signedDecimal" }, + "dividend_pnl": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/signedDecimal" }, + "execution_fees": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/signedDecimal" }, + "borrow_fees": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/signedDecimal" }, + "cash_interest": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/signedDecimal" }, + "total_fees": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/signedDecimal" }, + "cash_balances": { "type": "array", "minItems": 1, "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/cashAttribution" } }, + "positions": { "type": "array", "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/positionAttribution" } }, + "execution_fee_components": { "type": "array", "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/feeComponentAttribution" } } + } + } + } +} diff --git a/contracts/conformance/README.md b/contracts/conformance/README.md new file mode 100644 index 0000000..c6efdcf --- /dev/null +++ b/contracts/conformance/README.md @@ -0,0 +1,12 @@ +# Contract conformance corpus + +This directory is the machine-readable entry point for contract consumers. + +- `manifest.json` maps each current v1 schema to canonical fixtures. +- `cases.json` defines structural and semantic differential cases. + +Structural cases must agree between JSON Schema and the OCaml runtime. Semantic cases document +rules that JSON Schema cannot express, such as ordering, uniqueness, and cross-record invariants. + +Run `make check` to validate the complete corpus. A contract change must update its schema, +fixtures, manifest entries, differential cases, and runtime checks together. diff --git a/contracts/conformance/cases.json b/contracts/conformance/cases.json new file mode 100644 index 0000000..ae344ed --- /dev/null +++ b/contracts/conformance/cases.json @@ -0,0 +1,232 @@ +{ + "format_version": "1", + "cases": [ + { + "name": "scenario-v1-valid", + "artifact": "scenario-v1", + "kind": "scenario", + "source": "v1/fixtures/demo.scenario.json", + "mutations": [], + "schema_expectation": "accept", + "runtime_expectation": "accept", + "rule": "structural" + }, + { + "name": "scenario-missing-version", + "artifact": "scenario-v1", + "kind": "scenario", + "source": "v1/fixtures/demo.scenario.json", + "mutations": [ + { "op": "remove", "path": ["contract_version"] } + ], + "schema_expectation": "reject", + "runtime_expectation": "reject", + "rule": "structural" + }, + { + "name": "scenario-unknown-field", + "artifact": "scenario-v1", + "kind": "scenario", + "source": "v1/fixtures/demo.scenario.json", + "mutations": [ + { "op": "add", "path": ["unexpected_contract_field"], "value": true } + ], + "schema_expectation": "reject", + "runtime_expectation": "reject", + "rule": "structural" + }, + { + "name": "scenario-unsupported-execution-configuration", + "artifact": "scenario-v1", + "kind": "scenario", + "source": "v1/fixtures/demo.scenario.json", + "mutations": [ + { + "op": "replace", + "path": ["execution", "configuration", "version"], + "value": "unsupported" + } + ], + "schema_expectation": "reject", + "runtime_expectation": "reject", + "rule": "structural" + }, + { + "name": "scenario-duplicate-instrument", + "artifact": "scenario-v1", + "kind": "scenario", + "source": "v1/fixtures/demo.scenario.json", + "mutations": [ + { "op": "append_copy", "path": ["instruments"], "index": 0 } + ], + "schema_expectation": "accept", + "runtime_expectation": "reject", + "rule": "semantic" + }, + { + "name": "scenario-overlapping-slices", + "artifact": "scenario-v1", + "kind": "scenario", + "source": "v1/fixtures/demo.scenario.json", + "mutations": [ + { + "op": "replace", + "path": ["slices", 1, "start_at"], + "value": "2026-01-02T20:00:00Z" + } + ], + "schema_expectation": "accept", + "runtime_expectation": "reject", + "rule": "semantic" + }, + { + "name": "scenario-stream-v1-valid", + "artifact": "scenario-stream-v1", + "kind": "scenario_stream", + "source": "v1/fixtures/demo.scenario.jsonl", + "mutations": [], + "schema_expectation": "accept", + "runtime_expectation": "accept", + "rule": "structural" + }, + { + "name": "scenario-stream-missing-version", + "artifact": "scenario-stream-v1", + "kind": "scenario_stream", + "source": "v1/fixtures/demo.scenario.jsonl", + "record": 1, + "mutations": [ + { "op": "remove", "path": ["contract_version"] } + ], + "schema_expectation": "reject", + "runtime_expectation": "reject", + "rule": "structural" + }, + { + "name": "scenario-stream-out-of-order-sequence", + "artifact": "scenario-stream-v1", + "kind": "scenario_stream", + "source": "v1/fixtures/demo.scenario.jsonl", + "record": 2, + "mutations": [ + { "op": "replace", "path": ["scenario_sequence"], "value": "99" } + ], + "schema_expectation": "accept", + "runtime_expectation": "reject", + "rule": "semantic" + }, + { + "name": "strategy-ready-valid", + "artifact": "strategy-message-v1", + "kind": "strategy_response", + "source": "strategy/v1/fixtures/external.strategy.jsonl", + "record": 2, + "extract": ["message"], + "expected_sequence": "1", + "mutations": [], + "schema_expectation": "accept", + "runtime_expectation": "accept", + "rule": "structural" + }, + { + "name": "strategy-intents-valid", + "artifact": "strategy-message-v1", + "kind": "strategy_response", + "source": "strategy/v1/fixtures/external.strategy.jsonl", + "record": 4, + "extract": ["message"], + "expected_sequence": "2", + "mutations": [], + "schema_expectation": "accept", + "runtime_expectation": "accept", + "rule": "structural" + }, + { + "name": "strategy-stopped-valid", + "artifact": "strategy-message-v1", + "kind": "strategy_response", + "source": "strategy/v1/fixtures/external.strategy.jsonl", + "record": 14, + "extract": ["message"], + "expected_sequence": "7", + "mutations": [], + "schema_expectation": "accept", + "runtime_expectation": "accept", + "rule": "structural" + }, + { + "name": "strategy-missing-version", + "artifact": "strategy-message-v1", + "kind": "strategy_response", + "source": "strategy/v1/fixtures/external.strategy.jsonl", + "record": 2, + "extract": ["message"], + "expected_sequence": "1", + "mutations": [ + { "op": "remove", "path": ["strategy_protocol_version"] } + ], + "schema_expectation": "reject", + "runtime_expectation": "reject", + "rule": "structural" + }, + { + "name": "strategy-wrong-sequence", + "artifact": "strategy-message-v1", + "kind": "strategy_response", + "source": "strategy/v1/fixtures/external.strategy.jsonl", + "record": 2, + "extract": ["message"], + "expected_sequence": "1", + "mutations": [ + { "op": "replace", "path": ["strategy_sequence"], "value": "99" } + ], + "schema_expectation": "accept", + "runtime_expectation": "reject", + "rule": "semantic" + } + ], + "schema_only_cases": [ + { + "name": "strategy-error-valid", + "artifact": "strategy-message-v1", + "instance": { + "strategy_protocol_version": "1", + "strategy_sequence": "1", + "message_type": "error", + "payload": { "message": "fixture failure" } + }, + "mutations": [], + "schema_expectation": "accept", + "runtime_expectation": "accept", + "rule": "structural" + }, + { + "name": "strategy-rejected-response-valid", + "artifact": "strategy-transcript-v1", + "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": "invalid strategy response", + "context": { "json_path": "$", "sequence": "1" }, + "cause": null + }, + "evidence": { + "encoding": "hex", + "prefix": "7b", + "observed_bytes": 1, + "truncated": false + } + }, + "mutations": [], + "schema_expectation": "accept", + "runtime_expectation": "accept", + "rule": "structural" + } + ] +} diff --git a/contracts/conformance/dune b/contracts/conformance/dune new file mode 100644 index 0000000..7a4e1fa --- /dev/null +++ b/contracts/conformance/dune @@ -0,0 +1,7 @@ +(install + (section share) + (package trading_engine) + (files + (README.md as contracts/conformance/README.md) + (cases.json as contracts/conformance/cases.json) + (manifest.json as contracts/conformance/manifest.json))) diff --git a/contracts/conformance/manifest.json b/contracts/conformance/manifest.json new file mode 100644 index 0000000..20721a2 --- /dev/null +++ b/contracts/conformance/manifest.json @@ -0,0 +1,82 @@ +{ + "format_version": "1", + "artifacts": [ + { + "name": "scenario-v1", + "schema": "v1/scenario.schema.json", + "version_field": "contract_version", + "version": "1", + "sources": [ + { "path": "v1/fixtures/demo.scenario.json", "format": "json" }, + { "path": "v1/fixtures/fill-clipped.scenario.json", "format": "json" }, + { "path": "v1/fixtures/quote-trade.scenario.json", "format": "json" }, + { "path": "v1/fixtures/order-book.scenario.json", "format": "json" }, + { "path": "strategy/v1/fixtures/external.scenario.json", "format": "json" } + ] + }, + { + "name": "scenario-stream-v1", + "schema": "v1/scenario-stream.schema.json", + "version_field": "contract_version", + "version": "1", + "sources": [ + { "path": "v1/fixtures/demo.scenario.jsonl", "format": "jsonl" }, + { "path": "v1/fixtures/quote-trade.scenario.jsonl", "format": "jsonl" }, + { "path": "v1/fixtures/order-book.scenario.jsonl", "format": "jsonl" }, + { "path": "strategy/v1/fixtures/external.scenario.jsonl", "format": "jsonl" } + ] + }, + { + "name": "journal-v1", + "schema": "v1/journal.schema.json", + "version_field": "contract_version", + "version": "1", + "sources": [ + { "path": "v1/fixtures/demo.journal.jsonl", "format": "jsonl" }, + { "path": "v1/fixtures/fill-clipped.journal.jsonl", "format": "jsonl" }, + { "path": "v1/fixtures/quote-trade.journal.jsonl", "format": "jsonl" }, + { "path": "v1/fixtures/order-book.journal.jsonl", "format": "jsonl" } + ] + }, + { + "name": "strategy-message-v1", + "schema": "strategy/v1/message.schema.json", + "version_field": "strategy_protocol_version", + "version": "1", + "sources": [ + { + "path": "strategy/v1/fixtures/external.strategy.jsonl", + "format": "jsonl", + "extract": ["message"] + } + ] + }, + { + "name": "strategy-transcript-v1", + "schema": "strategy/v1/transcript.schema.json", + "version_field": "strategy_protocol_version", + "version": "1", + "sources": [ + { "path": "strategy/v1/fixtures/external.strategy.jsonl", "format": "jsonl" } + ] + }, + { + "name": "diagnostic-v1", + "schema": "diagnostic/v1/diagnostic.schema.json", + "version_field": "diagnostic_version", + "version": "1", + "sources": [ + { "path": "diagnostic/v1/fixtures/strategy-protocol.json", "format": "json" } + ] + }, + { + "name": "cli-result-v1", + "schema": "cli/v1/result.schema.json", + "version_field": "result_version", + "version": "1", + "sources": [ + { "path": "cli/v1/fixtures/demo.result.json", "format": "json" } + ] + } + ] +} diff --git a/contracts/diagnostic/v1/README.md b/contracts/diagnostic/v1/README.md new file mode 100644 index 0000000..ecfb773 --- /dev/null +++ b/contracts/diagnostic/v1/README.md @@ -0,0 +1,12 @@ +# Diagnostic contract v1 + +This directory defines the stable JSON emitted on standard error when the CLI uses +`--diagnostic-format json` or `--output-format json`. Validate each complete document against +[`diagnostic.schema.json`](diagnostic.schema.json). + +The `code` and typed `context` fields are the machine contract. Treat `message`, cause messages, +and human rendering as explanatory text. Unknown context is omitted. Diagnostics never retain an +input record, strategy response, or unrelated payload value. + +Adding a code or optional context field is compatible within version 1. Removing a code, changing +a field type, or changing a code's meaning requires a new diagnostic contract version. diff --git a/contracts/diagnostic/v1/diagnostic.schema.json b/contracts/diagnostic/v1/diagnostic.schema.json new file mode 100644 index 0000000..d420e0a --- /dev/null +++ b/contracts/diagnostic/v1/diagnostic.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/fallblu/trading-engine/contracts/diagnostic/v1/diagnostic.schema.json", + "title": "Trading Engine diagnostic contract v1", + "description": "One stable machine-readable process or file boundary failure.", + "type": "object", + "additionalProperties": false, + "required": ["diagnostic_version", "code", "phase", "message", "context", "cause"], + "properties": { + "diagnostic_version": { "const": "1" }, + "code": { + "enum": [ + "cli.invalid_arguments", + "input.io", + "scenario.invalid_json", + "scenario.invalid", + "scenario.unsupported_contract", + "scenario_stream.invalid", + "scenario_stream.changed", + "resource.limit", + "replay.failed", + "reducer.failed", + "strategy.invalid_configuration", + "strategy.protocol", + "strategy.timeout", + "strategy.process", + "strategy.exit", + "artifact.exists", + "artifact.io", + "artifact.state" + ] + }, + "phase": { + "enum": ["cli", "input", "validation", "replay", "reducer", "strategy", "artifact"] + }, + "message": { "type": "string", "minLength": 1 }, + "context": { "$ref": "#/$defs/context" }, + "cause": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/cause" } + ] + } + }, + "$defs": { + "canonicalSequence": { + "type": "string", + "pattern": "^[1-9][0-9]*$" + }, + "context": { + "type": "object", + "additionalProperties": false, + "properties": { + "json_path": { "type": "string", "minLength": 1 }, + "line": { "type": "integer", "minimum": 1 }, + "sequence": { "$ref": "#/$defs/canonicalSequence" }, + "event_id": { "type": "string", "minLength": 1 }, + "order_id": { "type": "string", "minLength": 1 }, + "causation_ids": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + } + }, + "cause": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "message"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "message": { "type": "string" }, + "operation": { "type": "string", "minLength": 1 }, + "target": { "type": "string" } + } + } + } +} diff --git a/contracts/diagnostic/v1/dune b/contracts/diagnostic/v1/dune new file mode 100644 index 0000000..47b3c59 --- /dev/null +++ b/contracts/diagnostic/v1/dune @@ -0,0 +1,8 @@ +(install + (section share) + (package trading_engine) + (files + (diagnostic.schema.json as contracts/diagnostic/v1/diagnostic.schema.json) + (fixtures/strategy-protocol.json + as + contracts/diagnostic/v1/fixtures/strategy-protocol.json))) diff --git a/contracts/diagnostic/v1/fixtures/strategy-protocol.json b/contracts/diagnostic/v1/fixtures/strategy-protocol.json new file mode 100644 index 0000000..064227b --- /dev/null +++ b/contracts/diagnostic/v1/fixtures/strategy-protocol.json @@ -0,0 +1,15 @@ +{ + "diagnostic_version": "1", + "code": "strategy.protocol", + "phase": "strategy", + "message": "strategy initialization: invalid strategy response JSON", + "context": { + "json_path": "$", + "line": 1, + "sequence": "1", + "event_id": "diagnostic-demo-event-000000000001", + "order_id": "diagnostic-demo-order-000000000001", + "causation_ids": ["diagnostic-demo-event-000000000000"] + }, + "cause": null +} diff --git a/contracts/strategy/v1/README.md b/contracts/strategy/v1/README.md index 4fa306f..8f72849 100644 --- a/contracts/strategy/v1/README.md +++ b/contracts/strategy/v1/README.md @@ -1,26 +1,17 @@ # External strategy protocol v1 -Version 1 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`. +Trading Engine supervises external strategies over synchronous JSON Lines on standard input and +output. `message.schema.json` defines protocol messages and `transcript.schema.json` defines the +durable exchange log. -Every message repeats `strategy_protocol_version: "1"` 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. +Every message carries `"strategy_protocol_version": "1"` and a positive decimal-string sequence. +The engine sends `initialize`, ordered `event` messages, and `shutdown`; the strategy replies with +`ready`, matching `intents`, and `stopped`. The runtime enforces direction, sequence pairing, +canonical values, message limits, and lifecycle order. -The event context contains the replay clock, every cash ledger and configured position, all -working orders, and the latest available bar for each instrument. Event payloads cover completed -market slices, fills, order updates, and rejected intents. Response intents use the scenario v3 -intent shapes. +Initialization binds the strategy to replay contract v1, the scenario hash, initial portfolio, +instruments, risk, execution, financing, settlement, venue calendars, and metadata. Event contexts +include the current portfolio, working orders, bars, and group exposures. -External replay requires an empty batch schedule and empty streamed intent batches. The engine -records both directions in a deterministic transcript. 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 retained transcript records. -- `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. +The fixtures contain a complete accepted session. Run `make check` to validate both schemas and +runtime behavior. diff --git a/contracts/strategy/v1/fixtures/external.scenario.json b/contracts/strategy/v1/fixtures/external.scenario.json index 5fe2d5a..04961cf 100644 --- a/contracts/strategy/v1/fixtures/external.scenario.json +++ b/contracts/strategy/v1/fixtures/external.scenario.json @@ -1,13 +1,26 @@ { - "contract_version": "3", + "contract_version": "1", "metadata": { "producer": "strategy-protocol-fixture" }, "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" + } + ] + }, "instruments": [ { "instrument_id": "demo-equity-acme", @@ -17,21 +30,148 @@ "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_order_quantity": "1000", - "max_long_position": "1000", - "max_short_position": "1000", "max_gross_exposure": "1000000", "max_leverage": "2", - "initial_margin_bps": 5000, - "maintenance_margin_bps": 2500, - "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", - "participation_bps": 10000, - "fixed_fee": "0", - "fee_bps": 0 + "configuration": { + "version": "1", + "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": [], @@ -43,10 +183,43 @@ "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" } + { + "instrument_id": "demo-equity-acme", + "open": "100", + "high": "105", + "low": "99", + "close": "104", + "volume": "100" + } + ], + "fx_rates": [ + { + "currency": "USD", + "rate": "1" + } ], - "fx_rates": [{ "currency": "USD", "rate": "1" }], - "corporate_actions": [] + "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": [], + "market_events": [], + "order_book_events": [] }, { "slice_sequence": "2", @@ -55,10 +228,80 @@ "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" } + { + "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 + } ], - "fx_rates": [{ "currency": "USD", "rate": "1" }], - "corporate_actions": [] + "cash_rate_observations": [ + { + "currency": "USD", + "effective_at": "2026-01-05T14:30:00Z", + "credit_rate_bps": 0, + "debit_rate_bps": 0 + } + ], + "settlement_failures": [], + "lifecycle_events": [], + "market_events": [], + "order_book_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/v1/fixtures/external.scenario.jsonl b/contracts/strategy/v1/fixtures/external.scenario.jsonl index 1e168bf..d0de398 100644 --- a/contracts/strategy/v1/fixtures/external.scenario.jsonl +++ b/contracts/strategy/v1/fixtures/external.scenario.jsonl @@ -1,4 +1,4 @@ -{"contract_version":"3","payload":{"base_currency":"USD","execution":{"fee_bps":0,"fixed_fee":"0","model":"completed_bar_v1","participation_bps":10000},"initial_cash":[{"amount":"10000","currency":"USD"}],"instruments":[{"instrument_id":"demo-equity-acme","lot_size":"1","quote_currency":"USD","symbol":"ACME","tick_size":"0.01"}],"max_internal_events":1000,"metadata":{"producer":"strategy-protocol-fixture"},"risk":{"initial_margin_bps":5000,"maintenance_margin_bps":2500,"max_gross_exposure":"1000000","max_leverage":"2","max_long_position":"1000","max_order_quantity":"1000","max_short_position":"1000","short_borrow_bps":0},"run_id":"external-demo"},"record_type":"scenario_header","scenario_sequence":"1"} -{"contract_version":"3","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"}},"record_type":"market_slice","scenario_sequence":"2"} -{"contract_version":"3","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"}},"record_type":"market_slice","scenario_sequence":"3"} -{"contract_version":"3","payload":{"slice_count":"2"},"record_type":"scenario_end","scenario_sequence":"4"} +{"contract_version":"1","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","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":"1","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":"1","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":[],"market_events":[],"order_book_events":[]}},"record_type":"market_slice","scenario_sequence":"2"} +{"contract_version":"1","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":[],"market_events":[],"order_book_events":[]}},"record_type":"market_slice","scenario_sequence":"3"} +{"contract_version":"1","payload":{"slice_count":"2"},"record_type":"scenario_end","scenario_sequence":"4"} diff --git a/contracts/strategy/v1/fixtures/external.strategy.jsonl b/contracts/strategy/v1/fixtures/external.strategy.jsonl index 46d40e5..36693da 100644 --- a/contracts/strategy/v1/fixtures/external.strategy.jsonl +++ b/contracts/strategy/v1/fixtures/external.strategy.jsonl @@ -1,14 +1,14 @@ -{"strategy_protocol_version":"1","transcript_sequence":"1","direction":"engine_to_strategy","message":{"strategy_protocol_version":"1","strategy_sequence":"1","message_type":"initialize","payload":{"engine_version":"0.1.0-dev","scenario_contract_version":"3","scenario_sha256":"78df448f65b0d784d51951108cacb1db247d9d2dd03d93886f43e4acc0e83e1d","run_id":"external-demo","base_currency":"USD","initial_cash":[{"currency":"USD","amount":"10000"}],"instruments":[{"instrument_id":"demo-equity-acme","symbol":"ACME","quote_currency":"USD","tick_size":"0.01","lot_size":"1"}],"risk":{"max_order_quantity":"1000","max_long_position":"1000","max_short_position":"1000","max_gross_exposure":"1000000","max_leverage":"2","initial_margin_bps":5000,"maintenance_margin_bps":2500,"short_borrow_bps":0},"execution":{"model":"completed_bar_v1","participation_bps":10000,"fixed_fee":"0","fee_bps":0},"metadata":{"producer":"strategy-protocol-fixture"}}}} +{"strategy_protocol_version":"1","transcript_sequence":"1","direction":"engine_to_strategy","message":{"strategy_protocol_version":"1","strategy_sequence":"1","message_type":"initialize","payload":{"engine_version":"1.1.0","scenario_contract_version":"1","scenario_sha256":"7c1991b8f4662c51faf8b3436999fdce8f0295ee6c0ef5d20a0658c960d04c4d","run_id":"external-demo","base_currency":"USD","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","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":"1","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":"1","transcript_sequence":"2","direction":"strategy_to_engine","message":{"strategy_protocol_version":"1","strategy_sequence":"1","message_type":"ready","payload":{"strategy_name":"fixture-strategy","strategy_version":"1"}}} -{"strategy_protocol_version":"1","transcript_sequence":"3","direction":"engine_to_strategy","message":{"strategy_protocol_version":"1","strategy_sequence":"2","message_type":"event","payload":{"context":{"now":"2026-01-02T21:00:02.000000Z","cash_balances":[{"currency":"USD","amount":"10000"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"0"}],"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":[]}}}}} -{"strategy_protocol_version":"1","transcript_sequence":"4","direction":"strategy_to_engine","message":{"strategy_protocol_version":"1","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":"1","transcript_sequence":"5","direction":"engine_to_strategy","message":{"strategy_protocol_version":"1","strategy_sequence":"3","message_type":"event","payload":{"context":{"now":"2026-01-02T21:00:02.000000Z","cash_balances":[{"currency":"USD","amount":"10000"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"0"}],"working_orders":[{"order_id":"external-demo-order-000000000001","instrument_id":"demo-equity-acme","side":"buy","quantity":"2","order_kind":"market","limit_price":null,"origin":"target_rebalance","created_event_id":"external-demo-event-000000000005","updated_event_id":"external-demo-event-000000000005","created_sequence":"5","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","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","limit_price":null,"origin":"target_rebalance","created_event_id":"external-demo-event-000000000005","updated_event_id":"external-demo-event-000000000005","created_sequence":"5","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","filled_quantity":"0","filled_notional":"0","status":"working","rejection_reason":null}}}}} +{"strategy_protocol_version":"1","transcript_sequence":"3","direction":"engine_to_strategy","message":{"strategy_protocol_version":"1","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":[],"market_events":[],"order_book_events":[]}}}}} +{"strategy_protocol_version":"1","transcript_sequence":"4","direction":"strategy_to_engine","message":{"strategy_protocol_version":"1","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":{"type":"numeric","value":"2"},"unit":"score","dimensions":{"source":"fixture"},"aggregation":"last"}]}}} +{"strategy_protocol_version":"1","transcript_sequence":"5","direction":"engine_to_strategy","message":{"strategy_protocol_version":"1","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":"1","transcript_sequence":"6","direction":"strategy_to_engine","message":{"strategy_protocol_version":"1","strategy_sequence":"3","message_type":"intents","payload":{"intents":[]}}} -{"strategy_protocol_version":"1","transcript_sequence":"7","direction":"engine_to_strategy","message":{"strategy_protocol_version":"1","strategy_sequence":"4","message_type":"event","payload":{"context":{"now":"2026-01-05T21:00:02.000000Z","cash_balances":[{"currency":"USD","amount":"9794"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"2"}],"working_orders":[],"latest_bars":[{"instrument_id":"demo-equity-acme","open":"100","high":"105","low":"99","close":"104","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","executed_at":"2026-01-05T14:30:00.000000Z","slice_sequence":"2"}}}}} +{"strategy_protocol_version":"1","transcript_sequence":"7","direction":"engine_to_strategy","message":{"strategy_protocol_version":"1","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","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"}],"executed_at":"2026-01-05T14:30:00.000000Z","slice_sequence":"2"}}}}} {"strategy_protocol_version":"1","transcript_sequence":"8","direction":"strategy_to_engine","message":{"strategy_protocol_version":"1","strategy_sequence":"4","message_type":"intents","payload":{"intents":[]}}} -{"strategy_protocol_version":"1","transcript_sequence":"9","direction":"engine_to_strategy","message":{"strategy_protocol_version":"1","strategy_sequence":"5","message_type":"event","payload":{"context":{"now":"2026-01-05T21:00:02.000000Z","cash_balances":[{"currency":"USD","amount":"9794"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"2"}],"working_orders":[],"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","limit_price":null,"origin":"target_rebalance","created_event_id":"external-demo-event-000000000005","updated_event_id":"external-demo-event-000000000005","created_sequence":"5","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","filled_quantity":"2","filled_notional":"206","status":"filled","rejection_reason":null}}}}} +{"strategy_protocol_version":"1","transcript_sequence":"9","direction":"engine_to_strategy","message":{"strategy_protocol_version":"1","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":"1","transcript_sequence":"10","direction":"strategy_to_engine","message":{"strategy_protocol_version":"1","strategy_sequence":"5","message_type":"intents","payload":{"intents":[]}}} -{"strategy_protocol_version":"1","transcript_sequence":"11","direction":"engine_to_strategy","message":{"strategy_protocol_version":"1","strategy_sequence":"6","message_type":"event","payload":{"context":{"now":"2026-01-05T21:00:02.000000Z","cash_balances":[{"currency":"USD","amount":"9794"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"2"}],"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":[]}}}}} +{"strategy_protocol_version":"1","transcript_sequence":"11","direction":"engine_to_strategy","message":{"strategy_protocol_version":"1","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":[],"market_events":[],"order_book_events":[]}}}}} {"strategy_protocol_version":"1","transcript_sequence":"12","direction":"strategy_to_engine","message":{"strategy_protocol_version":"1","strategy_sequence":"6","message_type":"intents","payload":{"intents":[]}}} {"strategy_protocol_version":"1","transcript_sequence":"13","direction":"engine_to_strategy","message":{"strategy_protocol_version":"1","strategy_sequence":"7","message_type":"shutdown","payload":{}}} {"strategy_protocol_version":"1","transcript_sequence":"14","direction":"strategy_to_engine","message":{"strategy_protocol_version":"1","strategy_sequence":"7","message_type":"stopped","payload":{}}} diff --git a/contracts/strategy/v1/message.schema.json b/contracts/strategy/v1/message.schema.json index 576c7a5..29ca3ff 100644 --- a/contracts/strategy/v1/message.schema.json +++ b/contracts/strategy/v1/message.schema.json @@ -42,25 +42,33 @@ "initializePayload": { "type": "object", "additionalProperties": false, - "required": ["engine_version", "scenario_contract_version", "scenario_sha256", "run_id", "base_currency", "initial_cash", "instruments", "risk", "execution", "metadata"], + "required": ["engine_version", "scenario_contract_version", "scenario_sha256", "run_id", "base_currency", "initial_portfolio", "instruments", "venue_calendars", "risk", "execution", "financing", "settlement", "metadata"], "properties": { "engine_version": { "type": "string", "minLength": 1 }, - "scenario_contract_version": { "type": "string", "minLength": 1 }, + "scenario_contract_version": { "const": "1" }, "scenario_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, - "run_id": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/identifier" }, - "base_currency": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/identifier" }, - "initial_cash": { - "type": "array", - "minItems": 1, - "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/cashBalance" } + "run_id": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/identifier" }, + "base_currency": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/identifier" }, + "initial_portfolio": { + "oneOf": [ + { "type": "null" }, + { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/initialPortfolio" } + ] }, "instruments": { "type": "array", "minItems": 1, - "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/instrument" } + "maxItems": 4096, + "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/instrument" } + }, + "venue_calendars": { + "type": "array", + "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/venueCalendar" } }, - "risk": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/risk" }, - "execution": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/execution" }, + "risk": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/risk" }, + "execution": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/execution" }, + "financing": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/financing" }, + "settlement": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/settlement" }, "metadata": { "type": "object" } } }, @@ -80,7 +88,7 @@ "additionalProperties": false, "required": ["strategy_name", "strategy_version"], "properties": { - "strategy_name": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/identifier" }, + "strategy_name": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/identifier" }, "strategy_version": { "oneOf": [ { "type": "null" }, @@ -112,36 +120,68 @@ "context": { "type": "object", "additionalProperties": false, - "required": ["now", "cash_balances", "positions", "working_orders", "latest_bars"], + "required": ["now", "portfolio", "working_orders", "latest_bars"], "properties": { - "now": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/timestamp" }, + "now": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/timestamp" }, + "portfolio": { "$ref": "#/$defs/portfolio" }, + "working_orders": { + "type": "array", + "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/order" } + }, + "latest_bars": { + "type": "array", + "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/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/v1/scenario.schema.json#/$defs/identifier" }, + "cash": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/signedDecimal" }, + "net_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/signedDecimal" }, + "long_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/unsignedDecimal" }, + "short_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/unsignedDecimal" }, + "gross_exposure": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/unsignedDecimal" }, + "equity": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/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/v3/scenario.schema.json#/$defs/cashBalance" } + "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/cashAttribution" } }, "positions": { "type": "array", "minItems": 1, - "items": { "$ref": "#/$defs/position" } - }, - "working_orders": { - "type": "array", - "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/journal.schema.json#/$defs/order" } + "items": { "$ref": "#/$defs/markedPosition" } }, - "latest_bars": { + "group_exposures": { "type": "array", - "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/bar" } + "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/groupExposure" } } } }, - "position": { + "optionalWeight": { + "oneOf": [ + { "type": "null" }, + { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/signedDecimal" } + ] + }, + "markedPosition": { "type": "object", "additionalProperties": false, - "required": ["instrument_id", "quantity"], + "required": ["instrument_id", "quantity", "settled_quantity", "unsettled_quantity", "mark", "base_market_value", "weight"], "properties": { - "instrument_id": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/identifier" }, - "quantity": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/signedDecimal" } + "instrument_id": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/identifier" }, + "quantity": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/signedDecimal" }, + "settled_quantity": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/signedDecimal" }, + "unsettled_quantity": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/signedDecimal" }, + "mark": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/positiveDecimal" }, + "base_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/signedDecimal" }, + "weight": { "$ref": "#/$defs/optionalWeight" } } }, "strategyEvent": { @@ -152,7 +192,7 @@ "required": ["type", "market_slice"], "properties": { "type": { "const": "market_slice_closed" }, - "market_slice": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/marketSlice" } + "market_slice": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/marketSlice" } } }, { @@ -161,7 +201,7 @@ "required": ["type", "fill"], "properties": { "type": { "const": "fill_received" }, - "fill": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/journal.schema.json#/$defs/fill" } + "fill": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/fill" } } }, { @@ -170,7 +210,7 @@ "required": ["type", "order"], "properties": { "type": { "const": "order_updated" }, - "order": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/journal.schema.json#/$defs/order" } + "order": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/journal.schema.json#/$defs/order" } } }, { @@ -202,7 +242,8 @@ "properties": { "intents": { "type": "array", - "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/intent" } + "maxItems": 4096, + "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/intent" } } } }, diff --git a/contracts/strategy/v1/transcript.schema.json b/contracts/strategy/v1/transcript.schema.json index c62a889..791e9cf 100644 --- a/contracts/strategy/v1/transcript.schema.json +++ b/contracts/strategy/v1/transcript.schema.json @@ -2,21 +2,81 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/fallblu/trading-engine/contracts/strategy/v1/transcript.schema.json", "title": "Trading Engine external strategy protocol v1 transcript record", - "description": "One ordered request or response retained from a supervised stdio strategy session.", - "type": "object", - "additionalProperties": false, - "required": ["strategy_protocol_version", "transcript_sequence", "direction", "message"], - "properties": { - "strategy_protocol_version": { "const": "1" }, - "transcript_sequence": { + "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]*$" }, - "direction": { - "enum": ["engine_to_strategy", "strategy_to_engine"] + "exchange": { + "type": "object", + "additionalProperties": false, + "required": ["strategy_protocol_version", "transcript_sequence", "direction", "message"], + "properties": { + "strategy_protocol_version": { "const": "1" }, + "transcript_sequence": { "$ref": "#/$defs/canonicalSequence" }, + "direction": { + "enum": ["engine_to_strategy", "strategy_to_engine"] + }, + "message": { + "$ref": "https://github.com/fallblu/trading-engine/contracts/strategy/v1/message.schema.json" + } + } }, - "message": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/strategy/v1/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/strategy/v2/README.md b/contracts/strategy/v2/README.md deleted file mode 100644 index 0420e34..0000000 --- a/contracts/strategy/v2/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# External strategy protocol v2 - -Version 2 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`. - -Every message repeats `strategy_protocol_version: "2"` 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, all working orders, -and the latest available bar for each instrument. 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. -Event payloads cover completed market slices, fills, order updates, and rejected intents. Response -intents use the scenario v3 intent shapes. - -External replay requires an empty batch schedule and empty streamed intent batches. The engine -records both directions in a deterministic transcript. 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 retained transcript records. -- `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/v2/dune b/contracts/strategy/v2/dune deleted file mode 100644 index cd3cf5a..0000000 --- a/contracts/strategy/v2/dune +++ /dev/null @@ -1,15 +0,0 @@ -(install - (section share) - (package trading_engine) - (files - (message.schema.json as contracts/strategy/v2/message.schema.json) - (transcript.schema.json as contracts/strategy/v2/transcript.schema.json) - (fixtures/external.scenario.json - as - contracts/strategy/v2/fixtures/external.scenario.json) - (fixtures/external.scenario.jsonl - as - contracts/strategy/v2/fixtures/external.scenario.jsonl) - (fixtures/external.strategy.jsonl - as - contracts/strategy/v2/fixtures/external.strategy.jsonl))) diff --git a/contracts/strategy/v2/fixtures/external.scenario.json b/contracts/strategy/v2/fixtures/external.scenario.json deleted file mode 100644 index 5fe2d5a..0000000 --- a/contracts/strategy/v2/fixtures/external.scenario.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "contract_version": "3", - "metadata": { - "producer": "strategy-protocol-fixture" - }, - "run_id": "external-demo", - "base_currency": "USD", - "initial_cash": [ - { "currency": "USD", "amount": "10000" } - ], - "instruments": [ - { - "instrument_id": "demo-equity-acme", - "symbol": "ACME", - "quote_currency": "USD", - "tick_size": "0.01", - "lot_size": "1" - } - ], - "risk": { - "max_order_quantity": "1000", - "max_long_position": "1000", - "max_short_position": "1000", - "max_gross_exposure": "1000000", - "max_leverage": "2", - "initial_margin_bps": 5000, - "maintenance_margin_bps": 2500, - "short_borrow_bps": 0 - }, - "execution": { - "model": "completed_bar_v1", - "participation_bps": 10000, - "fixed_fee": "0", - "fee_bps": 0 - }, - "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": [] - }, - { - "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": [] - } - ] -} diff --git a/contracts/strategy/v2/fixtures/external.scenario.jsonl b/contracts/strategy/v2/fixtures/external.scenario.jsonl deleted file mode 100644 index 1e168bf..0000000 --- a/contracts/strategy/v2/fixtures/external.scenario.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"contract_version":"3","payload":{"base_currency":"USD","execution":{"fee_bps":0,"fixed_fee":"0","model":"completed_bar_v1","participation_bps":10000},"initial_cash":[{"amount":"10000","currency":"USD"}],"instruments":[{"instrument_id":"demo-equity-acme","lot_size":"1","quote_currency":"USD","symbol":"ACME","tick_size":"0.01"}],"max_internal_events":1000,"metadata":{"producer":"strategy-protocol-fixture"},"risk":{"initial_margin_bps":5000,"maintenance_margin_bps":2500,"max_gross_exposure":"1000000","max_leverage":"2","max_long_position":"1000","max_order_quantity":"1000","max_short_position":"1000","short_borrow_bps":0},"run_id":"external-demo"},"record_type":"scenario_header","scenario_sequence":"1"} -{"contract_version":"3","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"}},"record_type":"market_slice","scenario_sequence":"2"} -{"contract_version":"3","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"}},"record_type":"market_slice","scenario_sequence":"3"} -{"contract_version":"3","payload":{"slice_count":"2"},"record_type":"scenario_end","scenario_sequence":"4"} diff --git a/contracts/strategy/v2/fixtures/external.strategy.jsonl b/contracts/strategy/v2/fixtures/external.strategy.jsonl deleted file mode 100644 index de2b8e7..0000000 --- a/contracts/strategy/v2/fixtures/external.strategy.jsonl +++ /dev/null @@ -1,14 +0,0 @@ -{"strategy_protocol_version":"2","transcript_sequence":"1","direction":"engine_to_strategy","message":{"strategy_protocol_version":"2","strategy_sequence":"1","message_type":"initialize","payload":{"engine_version":"0.1.0-dev","scenario_contract_version":"3","scenario_sha256":"78df448f65b0d784d51951108cacb1db247d9d2dd03d93886f43e4acc0e83e1d","run_id":"external-demo","base_currency":"USD","initial_cash":[{"currency":"USD","amount":"10000"}],"instruments":[{"instrument_id":"demo-equity-acme","symbol":"ACME","quote_currency":"USD","tick_size":"0.01","lot_size":"1"}],"risk":{"max_order_quantity":"1000","max_long_position":"1000","max_short_position":"1000","max_gross_exposure":"1000000","max_leverage":"2","initial_margin_bps":5000,"maintenance_margin_bps":2500,"short_borrow_bps":0},"execution":{"model":"completed_bar_v1","participation_bps":10000,"fixed_fee":"0","fee_bps":0},"metadata":{"producer":"strategy-protocol-fixture"}}}} -{"strategy_protocol_version":"2","transcript_sequence":"2","direction":"strategy_to_engine","message":{"strategy_protocol_version":"2","strategy_sequence":"1","message_type":"ready","payload":{"strategy_name":"fixture-strategy","strategy_version":"1"}}} -{"strategy_protocol_version":"2","transcript_sequence":"3","direction":"engine_to_strategy","message":{"strategy_protocol_version":"2","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"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"0","mark":"104","base_market_value":"0","weight":"0"}]},"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":[]}}}}} -{"strategy_protocol_version":"2","transcript_sequence":"4","direction":"strategy_to_engine","message":{"strategy_protocol_version":"2","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":"2","transcript_sequence":"5","direction":"engine_to_strategy","message":{"strategy_protocol_version":"2","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"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"0","mark":"104","base_market_value":"0","weight":"0"}]},"working_orders":[{"order_id":"external-demo-order-000000000001","instrument_id":"demo-equity-acme","side":"buy","quantity":"2","order_kind":"market","limit_price":null,"origin":"target_rebalance","created_event_id":"external-demo-event-000000000005","updated_event_id":"external-demo-event-000000000005","created_sequence":"5","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","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","limit_price":null,"origin":"target_rebalance","created_event_id":"external-demo-event-000000000005","updated_event_id":"external-demo-event-000000000005","created_sequence":"5","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","filled_quantity":"0","filled_notional":"0","status":"working","rejection_reason":null}}}}} -{"strategy_protocol_version":"2","transcript_sequence":"6","direction":"strategy_to_engine","message":{"strategy_protocol_version":"2","strategy_sequence":"3","message_type":"intents","payload":{"intents":[]}}} -{"strategy_protocol_version":"2","transcript_sequence":"7","direction":"engine_to_strategy","message":{"strategy_protocol_version":"2","strategy_sequence":"4","message_type":"event","payload":{"context":{"now":"2026-01-05T21:00:02.000000Z","portfolio":{"base_currency":"USD","cash":"9794","net_market_value":"208","long_market_value":"208","short_market_value":"0","gross_exposure":"208","equity":"10002","weights_available":true,"cash_weight":"0.979204","cash_balances":[{"currency":"USD","amount":"9794","fx_rate":"1","base_value":"9794"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"2","mark":"104","base_market_value":"208","weight":"0.020795"}]},"working_orders":[],"latest_bars":[{"instrument_id":"demo-equity-acme","open":"100","high":"105","low":"99","close":"104","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","executed_at":"2026-01-05T14:30:00.000000Z","slice_sequence":"2"}}}}} -{"strategy_protocol_version":"2","transcript_sequence":"8","direction":"strategy_to_engine","message":{"strategy_protocol_version":"2","strategy_sequence":"4","message_type":"intents","payload":{"intents":[]}}} -{"strategy_protocol_version":"2","transcript_sequence":"9","direction":"engine_to_strategy","message":{"strategy_protocol_version":"2","strategy_sequence":"5","message_type":"event","payload":{"context":{"now":"2026-01-05T21:00:02.000000Z","portfolio":{"base_currency":"USD","cash":"9794","net_market_value":"208","long_market_value":"208","short_market_value":"0","gross_exposure":"208","equity":"10002","weights_available":true,"cash_weight":"0.979204","cash_balances":[{"currency":"USD","amount":"9794","fx_rate":"1","base_value":"9794"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"2","mark":"104","base_market_value":"208","weight":"0.020795"}]},"working_orders":[],"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","limit_price":null,"origin":"target_rebalance","created_event_id":"external-demo-event-000000000005","updated_event_id":"external-demo-event-000000000005","created_sequence":"5","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","filled_quantity":"2","filled_notional":"206","status":"filled","rejection_reason":null}}}}} -{"strategy_protocol_version":"2","transcript_sequence":"10","direction":"strategy_to_engine","message":{"strategy_protocol_version":"2","strategy_sequence":"5","message_type":"intents","payload":{"intents":[]}}} -{"strategy_protocol_version":"2","transcript_sequence":"11","direction":"engine_to_strategy","message":{"strategy_protocol_version":"2","strategy_sequence":"6","message_type":"event","payload":{"context":{"now":"2026-01-05T21:00:02.000000Z","portfolio":{"base_currency":"USD","cash":"9794","net_market_value":"214","long_market_value":"214","short_market_value":"0","gross_exposure":"214","equity":"10008","weights_available":true,"cash_weight":"0.978617","cash_balances":[{"currency":"USD","amount":"9794","fx_rate":"1","base_value":"9794"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"2","mark":"107","base_market_value":"214","weight":"0.021382"}]},"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":[]}}}}} -{"strategy_protocol_version":"2","transcript_sequence":"12","direction":"strategy_to_engine","message":{"strategy_protocol_version":"2","strategy_sequence":"6","message_type":"intents","payload":{"intents":[]}}} -{"strategy_protocol_version":"2","transcript_sequence":"13","direction":"engine_to_strategy","message":{"strategy_protocol_version":"2","strategy_sequence":"7","message_type":"shutdown","payload":{}}} -{"strategy_protocol_version":"2","transcript_sequence":"14","direction":"strategy_to_engine","message":{"strategy_protocol_version":"2","strategy_sequence":"7","message_type":"stopped","payload":{}}} diff --git a/contracts/strategy/v2/message.schema.json b/contracts/strategy/v2/message.schema.json deleted file mode 100644 index a3f9dee..0000000 --- a/contracts/strategy/v2/message.schema.json +++ /dev/null @@ -1,282 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/fallblu/trading-engine/contracts/strategy/v2/message.schema.json", - "title": "Trading Engine external strategy protocol v2 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": "2" }, - "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", "instruments", "risk", "execution", "metadata"], - "properties": { - "engine_version": { "type": "string", "minLength": 1 }, - "scenario_contract_version": { "type": "string", "minLength": 1 }, - "scenario_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, - "run_id": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/identifier" }, - "base_currency": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/identifier" }, - "initial_cash": { - "type": "array", - "minItems": 1, - "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/cashBalance" } - }, - "instruments": { - "type": "array", - "minItems": 1, - "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/instrument" } - }, - "risk": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/risk" }, - "execution": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/execution" }, - "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/v3/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/v3/scenario.schema.json#/$defs/timestamp" }, - "portfolio": { "$ref": "#/$defs/portfolio" }, - "working_orders": { - "type": "array", - "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/journal.schema.json#/$defs/order" } - }, - "latest_bars": { - "type": "array", - "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/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"], - "properties": { - "base_currency": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/identifier" }, - "cash": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/signedDecimal" }, - "net_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/signedDecimal" }, - "long_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/unsignedDecimal" }, - "short_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/unsignedDecimal" }, - "gross_exposure": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/unsignedDecimal" }, - "equity": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/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/v3/journal.schema.json#/$defs/cashAttribution" } - }, - "positions": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/markedPosition" } - } - } - }, - "optionalWeight": { - "oneOf": [ - { "type": "null" }, - { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/signedDecimal" } - ] - }, - "markedPosition": { - "type": "object", - "additionalProperties": false, - "required": ["instrument_id", "quantity", "mark", "base_market_value", "weight"], - "properties": { - "instrument_id": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/identifier" }, - "quantity": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/signedDecimal" }, - "mark": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/positiveDecimal" }, - "base_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/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/v3/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/v3/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/v3/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", - "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/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/v2/transcript.schema.json b/contracts/strategy/v2/transcript.schema.json deleted file mode 100644 index bf84b7b..0000000 --- a/contracts/strategy/v2/transcript.schema.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/fallblu/trading-engine/contracts/strategy/v2/transcript.schema.json", - "title": "Trading Engine external strategy protocol v2 transcript record", - "description": "One ordered request or response retained from a supervised stdio strategy session.", - "type": "object", - "additionalProperties": false, - "required": ["strategy_protocol_version", "transcript_sequence", "direction", "message"], - "properties": { - "strategy_protocol_version": { "const": "2" }, - "transcript_sequence": { - "type": "string", - "pattern": "^[1-9][0-9]*$" - }, - "direction": { - "enum": ["engine_to_strategy", "strategy_to_engine"] - }, - "message": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/strategy/v2/message.schema.json" - } - } -} diff --git a/contracts/strategy/v3/README.md b/contracts/strategy/v3/README.md deleted file mode 100644 index bafe9ce..0000000 --- a/contracts/strategy/v3/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# External strategy protocol v3 - -Version 3 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`. - -Every message repeats `strategy_protocol_version: "3"` 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, 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. - -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, fills, order updates, and rejected intents. Response -intents use the scenario v3 intent shapes. - -External replay requires an empty batch schedule and empty streamed intent batches. The engine -records both directions in a deterministic transcript. 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 retained transcript records. -- `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/v3/dune b/contracts/strategy/v3/dune deleted file mode 100644 index ec57607..0000000 --- a/contracts/strategy/v3/dune +++ /dev/null @@ -1,15 +0,0 @@ -(install - (section share) - (package trading_engine) - (files - (message.schema.json as contracts/strategy/v3/message.schema.json) - (transcript.schema.json as contracts/strategy/v3/transcript.schema.json) - (fixtures/external.scenario.json - as - contracts/strategy/v3/fixtures/external.scenario.json) - (fixtures/external.scenario.jsonl - as - contracts/strategy/v3/fixtures/external.scenario.jsonl) - (fixtures/external.strategy.jsonl - as - contracts/strategy/v3/fixtures/external.strategy.jsonl))) diff --git a/contracts/strategy/v3/fixtures/external.scenario.json b/contracts/strategy/v3/fixtures/external.scenario.json deleted file mode 100644 index 5fe2d5a..0000000 --- a/contracts/strategy/v3/fixtures/external.scenario.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "contract_version": "3", - "metadata": { - "producer": "strategy-protocol-fixture" - }, - "run_id": "external-demo", - "base_currency": "USD", - "initial_cash": [ - { "currency": "USD", "amount": "10000" } - ], - "instruments": [ - { - "instrument_id": "demo-equity-acme", - "symbol": "ACME", - "quote_currency": "USD", - "tick_size": "0.01", - "lot_size": "1" - } - ], - "risk": { - "max_order_quantity": "1000", - "max_long_position": "1000", - "max_short_position": "1000", - "max_gross_exposure": "1000000", - "max_leverage": "2", - "initial_margin_bps": 5000, - "maintenance_margin_bps": 2500, - "short_borrow_bps": 0 - }, - "execution": { - "model": "completed_bar_v1", - "participation_bps": 10000, - "fixed_fee": "0", - "fee_bps": 0 - }, - "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": [] - }, - { - "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": [] - } - ] -} diff --git a/contracts/strategy/v3/fixtures/external.scenario.jsonl b/contracts/strategy/v3/fixtures/external.scenario.jsonl deleted file mode 100644 index 1e168bf..0000000 --- a/contracts/strategy/v3/fixtures/external.scenario.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"contract_version":"3","payload":{"base_currency":"USD","execution":{"fee_bps":0,"fixed_fee":"0","model":"completed_bar_v1","participation_bps":10000},"initial_cash":[{"amount":"10000","currency":"USD"}],"instruments":[{"instrument_id":"demo-equity-acme","lot_size":"1","quote_currency":"USD","symbol":"ACME","tick_size":"0.01"}],"max_internal_events":1000,"metadata":{"producer":"strategy-protocol-fixture"},"risk":{"initial_margin_bps":5000,"maintenance_margin_bps":2500,"max_gross_exposure":"1000000","max_leverage":"2","max_long_position":"1000","max_order_quantity":"1000","max_short_position":"1000","short_borrow_bps":0},"run_id":"external-demo"},"record_type":"scenario_header","scenario_sequence":"1"} -{"contract_version":"3","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"}},"record_type":"market_slice","scenario_sequence":"2"} -{"contract_version":"3","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"}},"record_type":"market_slice","scenario_sequence":"3"} -{"contract_version":"3","payload":{"slice_count":"2"},"record_type":"scenario_end","scenario_sequence":"4"} diff --git a/contracts/strategy/v3/fixtures/external.strategy.jsonl b/contracts/strategy/v3/fixtures/external.strategy.jsonl deleted file mode 100644 index 6839dc6..0000000 --- a/contracts/strategy/v3/fixtures/external.strategy.jsonl +++ /dev/null @@ -1,14 +0,0 @@ -{"strategy_protocol_version":"3","transcript_sequence":"1","direction":"engine_to_strategy","message":{"strategy_protocol_version":"3","strategy_sequence":"1","message_type":"initialize","payload":{"engine_version":"1.0.0","scenario_contract_version":"3","scenario_sha256":"78df448f65b0d784d51951108cacb1db247d9d2dd03d93886f43e4acc0e83e1d","run_id":"external-demo","base_currency":"USD","initial_cash":[{"currency":"USD","amount":"10000"}],"instruments":[{"instrument_id":"demo-equity-acme","symbol":"ACME","quote_currency":"USD","tick_size":"0.01","lot_size":"1"}],"risk":{"max_order_quantity":"1000","max_long_position":"1000","max_short_position":"1000","max_gross_exposure":"1000000","max_leverage":"2","initial_margin_bps":5000,"maintenance_margin_bps":2500,"short_borrow_bps":0},"execution":{"model":"completed_bar_v1","participation_bps":10000,"fixed_fee":"0","fee_bps":0},"metadata":{"producer":"strategy-protocol-fixture"}}}} -{"strategy_protocol_version":"3","transcript_sequence":"2","direction":"strategy_to_engine","message":{"strategy_protocol_version":"3","strategy_sequence":"1","message_type":"ready","payload":{"strategy_name":"fixture-strategy","strategy_version":"1"}}} -{"strategy_protocol_version":"3","transcript_sequence":"3","direction":"engine_to_strategy","message":{"strategy_protocol_version":"3","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"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"0","mark":"104","base_market_value":"0","weight":"0"}]},"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":[]}}}}} -{"strategy_protocol_version":"3","transcript_sequence":"4","direction":"strategy_to_engine","message":{"strategy_protocol_version":"3","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":"3","transcript_sequence":"5","direction":"engine_to_strategy","message":{"strategy_protocol_version":"3","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"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"0","mark":"104","base_market_value":"0","weight":"0"}]},"working_orders":[{"order_id":"external-demo-order-000000000001","instrument_id":"demo-equity-acme","side":"buy","quantity":"2","order_kind":"market","limit_price":null,"origin":"target_rebalance","created_event_id":"external-demo-event-000000000005","updated_event_id":"external-demo-event-000000000005","created_sequence":"5","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","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","limit_price":null,"origin":"target_rebalance","created_event_id":"external-demo-event-000000000005","updated_event_id":"external-demo-event-000000000005","created_sequence":"5","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","filled_quantity":"0","filled_notional":"0","status":"working","rejection_reason":null}}}}} -{"strategy_protocol_version":"3","transcript_sequence":"6","direction":"strategy_to_engine","message":{"strategy_protocol_version":"3","strategy_sequence":"3","message_type":"intents","payload":{"intents":[]}}} -{"strategy_protocol_version":"3","transcript_sequence":"7","direction":"engine_to_strategy","message":{"strategy_protocol_version":"3","strategy_sequence":"4","message_type":"event","payload":{"context":{"now":"2026-01-05T21:00:02.000000Z","portfolio":{"base_currency":"USD","cash":"9794","net_market_value":"214","long_market_value":"214","short_market_value":"0","gross_exposure":"214","equity":"10008","weights_available":true,"cash_weight":"0.978617","cash_balances":[{"currency":"USD","amount":"9794","fx_rate":"1","base_value":"9794"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"2","mark":"107","base_market_value":"214","weight":"0.021382"}]},"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","executed_at":"2026-01-05T14:30:00.000000Z","slice_sequence":"2"}}}}} -{"strategy_protocol_version":"3","transcript_sequence":"8","direction":"strategy_to_engine","message":{"strategy_protocol_version":"3","strategy_sequence":"4","message_type":"intents","payload":{"intents":[]}}} -{"strategy_protocol_version":"3","transcript_sequence":"9","direction":"engine_to_strategy","message":{"strategy_protocol_version":"3","strategy_sequence":"5","message_type":"event","payload":{"context":{"now":"2026-01-05T21:00:02.000000Z","portfolio":{"base_currency":"USD","cash":"9794","net_market_value":"214","long_market_value":"214","short_market_value":"0","gross_exposure":"214","equity":"10008","weights_available":true,"cash_weight":"0.978617","cash_balances":[{"currency":"USD","amount":"9794","fx_rate":"1","base_value":"9794"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"2","mark":"107","base_market_value":"214","weight":"0.021382"}]},"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","limit_price":null,"origin":"target_rebalance","created_event_id":"external-demo-event-000000000005","updated_event_id":"external-demo-event-000000000005","created_sequence":"5","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","filled_quantity":"2","filled_notional":"206","status":"filled","rejection_reason":null}}}}} -{"strategy_protocol_version":"3","transcript_sequence":"10","direction":"strategy_to_engine","message":{"strategy_protocol_version":"3","strategy_sequence":"5","message_type":"intents","payload":{"intents":[]}}} -{"strategy_protocol_version":"3","transcript_sequence":"11","direction":"engine_to_strategy","message":{"strategy_protocol_version":"3","strategy_sequence":"6","message_type":"event","payload":{"context":{"now":"2026-01-05T21:00:02.000000Z","portfolio":{"base_currency":"USD","cash":"9794","net_market_value":"214","long_market_value":"214","short_market_value":"0","gross_exposure":"214","equity":"10008","weights_available":true,"cash_weight":"0.978617","cash_balances":[{"currency":"USD","amount":"9794","fx_rate":"1","base_value":"9794"}],"positions":[{"instrument_id":"demo-equity-acme","quantity":"2","mark":"107","base_market_value":"214","weight":"0.021382"}]},"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":[]}}}}} -{"strategy_protocol_version":"3","transcript_sequence":"12","direction":"strategy_to_engine","message":{"strategy_protocol_version":"3","strategy_sequence":"6","message_type":"intents","payload":{"intents":[]}}} -{"strategy_protocol_version":"3","transcript_sequence":"13","direction":"engine_to_strategy","message":{"strategy_protocol_version":"3","strategy_sequence":"7","message_type":"shutdown","payload":{}}} -{"strategy_protocol_version":"3","transcript_sequence":"14","direction":"strategy_to_engine","message":{"strategy_protocol_version":"3","strategy_sequence":"7","message_type":"stopped","payload":{}}} diff --git a/contracts/strategy/v3/message.schema.json b/contracts/strategy/v3/message.schema.json deleted file mode 100644 index b9a293f..0000000 --- a/contracts/strategy/v3/message.schema.json +++ /dev/null @@ -1,282 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/fallblu/trading-engine/contracts/strategy/v3/message.schema.json", - "title": "Trading Engine external strategy protocol v3 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": "3" }, - "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", "instruments", "risk", "execution", "metadata"], - "properties": { - "engine_version": { "type": "string", "minLength": 1 }, - "scenario_contract_version": { "type": "string", "minLength": 1 }, - "scenario_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, - "run_id": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/identifier" }, - "base_currency": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/identifier" }, - "initial_cash": { - "type": "array", - "minItems": 1, - "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/cashBalance" } - }, - "instruments": { - "type": "array", - "minItems": 1, - "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/instrument" } - }, - "risk": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/risk" }, - "execution": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/execution" }, - "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/v3/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/v3/scenario.schema.json#/$defs/timestamp" }, - "portfolio": { "$ref": "#/$defs/portfolio" }, - "working_orders": { - "type": "array", - "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/journal.schema.json#/$defs/order" } - }, - "latest_bars": { - "type": "array", - "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/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"], - "properties": { - "base_currency": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/identifier" }, - "cash": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/signedDecimal" }, - "net_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/signedDecimal" }, - "long_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/unsignedDecimal" }, - "short_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/unsignedDecimal" }, - "gross_exposure": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/unsignedDecimal" }, - "equity": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/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/v3/journal.schema.json#/$defs/cashAttribution" } - }, - "positions": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/markedPosition" } - } - } - }, - "optionalWeight": { - "oneOf": [ - { "type": "null" }, - { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/signedDecimal" } - ] - }, - "markedPosition": { - "type": "object", - "additionalProperties": false, - "required": ["instrument_id", "quantity", "mark", "base_market_value", "weight"], - "properties": { - "instrument_id": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/identifier" }, - "quantity": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/signedDecimal" }, - "mark": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/positiveDecimal" }, - "base_market_value": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/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/v3/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/v3/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/v3/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", - "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/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/v3/transcript.schema.json b/contracts/strategy/v3/transcript.schema.json deleted file mode 100644 index 1e21553..0000000 --- a/contracts/strategy/v3/transcript.schema.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/fallblu/trading-engine/contracts/strategy/v3/transcript.schema.json", - "title": "Trading Engine external strategy protocol v3 transcript record", - "description": "One ordered request or response retained from a supervised stdio strategy session.", - "type": "object", - "additionalProperties": false, - "required": ["strategy_protocol_version", "transcript_sequence", "direction", "message"], - "properties": { - "strategy_protocol_version": { "const": "3" }, - "transcript_sequence": { - "type": "string", - "pattern": "^[1-9][0-9]*$" - }, - "direction": { - "enum": ["engine_to_strategy", "strategy_to_engine"] - }, - "message": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/strategy/v3/message.schema.json" - } - } -} diff --git a/contracts/v1/README.md b/contracts/v1/README.md index e925ceb..0bd77e3 100644 --- a/contracts/v1/README.md +++ b/contracts/v1/README.md @@ -1,15 +1,19 @@ -# Trading Engine contract v1 +# Replay contract v1 -This frozen directory preserves the historical v1 process and file contract. The current runtime -emits and advertises v3 only; these artifacts remain available for provenance and compatibility -testing by older consumers. +This directory is the authoritative replay contract for Trading Engine. -- `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. -- `fixtures/demo.scenario.json`, `fixtures/demo.scenario.jsonl`, and - `fixtures/demo.journal.jsonl` form the canonical valid conformance corpus. +- `scenario.schema.json` defines batch replay input. +- `scenario-stream.schema.json` defines the equivalent JSON Lines stream. +- `journal.schema.json` defines append-only audit records. +- `fixtures/` contains canonical scenarios and journals used by the conformance suite. -Every batch scenario, scenario-stream record, and journal record carries -`"contract_version": "1"`. Consumers must reject missing or unsupported versions before -interpreting the rest of a document. +Every scenario, stream record, and journal record carries `"contract_version": "1"`. +Objects are strict unless a field is explicitly open, decimal values use canonical strings, and +timestamps are bounded RFC 3339 instants. Runtime validation additionally enforces uniqueness, +causal ordering, non-overlapping slices, resource limits, and configuration coverage. + +The contract models an explicit initial portfolio, instrument-level and grouped risk policy, +execution and fee schedules, financing, settlement, venue calendars, lifecycle events, market +data, strategy intents, and causal audit output. A successful replay ends with `run_completed`. + +Run `make check` to validate schemas, fixtures, runtime behavior, and deterministic output. diff --git a/contracts/v1/dune b/contracts/v1/dune index fe01ab1..0f6b798 100644 --- a/contracts/v1/dune +++ b/contracts/v1/dune @@ -7,4 +7,28 @@ (scenario.schema.json as contracts/v1/scenario.schema.json) (fixtures/demo.journal.jsonl as contracts/v1/fixtures/demo.journal.jsonl) (fixtures/demo.scenario.json as contracts/v1/fixtures/demo.scenario.json) - (fixtures/demo.scenario.jsonl as contracts/v1/fixtures/demo.scenario.jsonl))) + (fixtures/demo.scenario.jsonl as contracts/v1/fixtures/demo.scenario.jsonl) + (fixtures/fill-clipped.journal.jsonl + as + contracts/v1/fixtures/fill-clipped.journal.jsonl) + (fixtures/fill-clipped.scenario.json + as + contracts/v1/fixtures/fill-clipped.scenario.json) + (fixtures/quote-trade.journal.jsonl + as + contracts/v1/fixtures/quote-trade.journal.jsonl) + (fixtures/quote-trade.scenario.json + as + contracts/v1/fixtures/quote-trade.scenario.json) + (fixtures/quote-trade.scenario.jsonl + as + contracts/v1/fixtures/quote-trade.scenario.jsonl) + (fixtures/order-book.journal.jsonl + as + contracts/v1/fixtures/order-book.journal.jsonl) + (fixtures/order-book.scenario.json + as + contracts/v1/fixtures/order-book.scenario.json) + (fixtures/order-book.scenario.jsonl + as + contracts/v1/fixtures/order-book.scenario.jsonl))) diff --git a/contracts/v1/fixtures/demo.journal.jsonl b/contracts/v1/fixtures/demo.journal.jsonl index 4d59346..cdafdd3 100644 --- a/contracts/v1/fixtures/demo.journal.jsonl +++ b/contracts/v1/fixtures/demo.journal.jsonl @@ -1,20 +1,34 @@ -{"contract_version":"1","engine_sequence":"1","run_id":"demo","recorded_at":"2026-01-02T21:00:02.000000Z","event_type":"run_started","payload":{"scenario_sha256":"a782fbbee8b89332ee6491d9d9be36bf7f2aaacbd440d30c20ed033fd7566d19"}} -{"contract_version":"1","engine_sequence":"2","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"}]}} -{"contract_version":"1","engine_sequence":"3","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","reference_price":"104"}]}} -{"contract_version":"1","engine_sequence":"4","run_id":"demo","recorded_at":"2026-01-02T21:00:02.000000Z","event_type":"metric_emitted","payload":{"name":"desired_weight","value":"0.1"}} -{"contract_version":"1","engine_sequence":"5","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":"9","order_kind":"market","limit_price":null,"origin":"target_rebalance","created_sequence":"5","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","filled_quantity":"0","filled_notional":"0","status":"working","rejection_reason":null}} -{"contract_version":"1","engine_sequence":"6","run_id":"demo","recorded_at":"2026-01-02T21:00:02.000000Z","event_type":"valuation","payload":{"cash":"10000","market_value":"0","cost_basis":"0","realized_pnl":"0","unrealized_pnl":"0","equity":"10000","total_fees":"0"}} -{"contract_version":"1","engine_sequence":"7","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"}]}} -{"contract_version":"1","engine_sequence":"8","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","side":"buy","quantity":"6","price":"103","notional":"618","fee":"0.868","executed_at":"2026-01-05T14:30:00.000000Z","slice_sequence":"2"}} -{"contract_version":"1","engine_sequence":"9","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":"9","order_kind":"market","limit_price":null,"origin":"target_rebalance","created_sequence":"5","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","filled_quantity":"6","filled_notional":"618","status":"cancelled","rejection_reason":null},"reason":"market_ioc"}} -{"contract_version":"1","engine_sequence":"10","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":"3","order_kind":"market","limit_price":null,"origin":"target_rebalance","created_sequence":"10","created_at":"2026-01-05T21:00:02.000000Z","eligible_after_slice_sequence":"2","filled_quantity":"0","filled_notional":"0","status":"working","rejection_reason":null}} -{"contract_version":"1","engine_sequence":"11","run_id":"demo","recorded_at":"2026-01-05T21:00:02.000000Z","event_type":"valuation","payload":{"cash":"9381.132","market_value":"642","cost_basis":"618.868","realized_pnl":"0","unrealized_pnl":"23.132","equity":"10023.132","total_fees":"0.868"}} -{"contract_version":"1","engine_sequence":"12","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"}]}} -{"contract_version":"1","engine_sequence":"13","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","side":"buy","quantity":"3","price":"107","notional":"321","fee":"0.571","executed_at":"2026-01-06T14:30:00.000000Z","slice_sequence":"3"}} -{"contract_version":"1","engine_sequence":"14","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","reference_price":null}]}} -{"contract_version":"1","engine_sequence":"15","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","order_kind":"market","limit_price":null,"origin":"target_rebalance","created_sequence":"15","created_at":"2026-01-06T21:00:02.000000Z","eligible_after_slice_sequence":"3","filled_quantity":"0","filled_notional":"0","status":"working","rejection_reason":null}} -{"contract_version":"1","engine_sequence":"16","run_id":"demo","recorded_at":"2026-01-06T21:00:02.000000Z","event_type":"valuation","payload":{"cash":"9059.561","market_value":"945","cost_basis":"940.439","realized_pnl":"0","unrealized_pnl":"4.561","equity":"10004.561","total_fees":"1.439"}} -{"contract_version":"1","engine_sequence":"17","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"}]}} -{"contract_version":"1","engine_sequence":"18","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","side":"sell","quantity":"7","price":"105","notional":"735","fee":"0.985","executed_at":"2026-01-07T14:30:00.000000Z","slice_sequence":"4"}} -{"contract_version":"1","engine_sequence":"19","run_id":"demo","recorded_at":"2026-01-07T21:00:02.000000Z","event_type":"valuation","payload":{"cash":"9793.576","market_value":"212","cost_basis":"208.986445","realized_pnl":"2.562445","unrealized_pnl":"3.013555","equity":"10005.576","total_fees":"2.424"}} -{"contract_version":"1","engine_sequence":"20","run_id":"demo","recorded_at":"2026-01-07T21:00:02.000000Z","event_type":"run_completed","payload":{"scenario_sha256":"a782fbbee8b89332ee6491d9d9be36bf7f2aaacbd440d30c20ed033fd7566d19","valuation":{"cash":"9793.576","market_value":"212","cost_basis":"208.986445","realized_pnl":"2.562445","unrealized_pnl":"3.013555","equity":"10005.576","total_fees":"2.424"},"order_counts":{"total":3,"active":0,"filled":2,"rejected":0,"cancelled":1}}} +{"contract_version":"1","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":"e2227af76072fab8151c3e1bd86f401293d16a736e32040efdaf8761cd397574","execution_model":"completed_bar_adverse_touch_v1"}} +{"contract_version":"1","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","execution_fee_components":[],"borrow_fees":"0.25","total_fees":"0.75","cash_interest":"0","settled_cash":"10000","unsettled_cash":"0","cash_balances":[{"currency":"USD","amount":"10000","settled_amount":"10000","unsettled_amount":"0","fx_rate":"1","base_value":"10000","base_settled_value":"10000","base_unsettled_value":"0","interest":"0","base_interest":"0"}],"positions":[{"instrument_id":"demo-equity-acme","quote_currency":"USD","quantity":"1","settled_quantity":"1","unsettled_quantity":"0","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","execution_fee_components":[],"borrow_fees":"0.25","base_borrow_fees":"0.25","total_fees":"0.75","base_total_fees":"0.75"}],"margin":{"initial_requirement":"50","maintenance_requirement":"25","initial_excess":"10050","maintenance_excess":"10075","margin_call":false},"group_exposures":[]}}} +{"contract_version":"1","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","execution_fee_components":[],"borrow_fees":"0.25","total_fees":"0.75","cash_interest":"0","settled_cash":"10000","unsettled_cash":"0","cash_balances":[{"currency":"USD","amount":"10000","settled_amount":"10000","unsettled_amount":"0","fx_rate":"1","base_value":"10000","base_settled_value":"10000","base_unsettled_value":"0","interest":"0","base_interest":"0"}],"positions":[{"instrument_id":"demo-equity-acme","quote_currency":"USD","quantity":"1","settled_quantity":"1","unsettled_quantity":"0","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","execution_fee_components":[],"borrow_fees":"0.25","base_borrow_fees":"0.25","total_fees":"0.75","base_total_fees":"0.75"}],"margin":{"initial_requirement":"50","maintenance_requirement":"25","initial_excess":"10050","maintenance_excess":"10075","margin_call":false},"group_exposures":[]}} +{"contract_version":"1","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":[],"market_events":[],"order_book_events":[]}} +{"contract_version":"1","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":"1","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":"1","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":{"type":"numeric","value":"0.1"},"unit":"ratio","dimensions":{"instrument":"demo-equity-acme","source":"strategy"},"aggregation":"last"}} +{"contract_version":"1","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":"1","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","execution_fee_components":[],"borrow_fees":"0.25","total_fees":"0.75","cash_interest":"0.074201","settled_cash":"10000.074201","unsettled_cash":"0","cash_balances":[{"currency":"USD","amount":"10000.074201","settled_amount":"10000.074201","unsettled_amount":"0","fx_rate":"1","base_value":"10000.074201","base_settled_value":"10000.074201","base_unsettled_value":"0","interest":"0.074201","base_interest":"0.074201"}],"positions":[{"instrument_id":"demo-equity-acme","quote_currency":"USD","quantity":"1","settled_quantity":"1","unsettled_quantity":"0","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","execution_fee_components":[],"borrow_fees":"0.25","base_borrow_fees":"0.25","total_fees":"0.75","base_total_fees":"0.75"}],"margin":{"initial_requirement":"52","maintenance_requirement":"26","initial_excess":"10052.074201","maintenance_excess":"10078.074201","margin_call":false},"group_exposures":[]}} +{"contract_version":"1","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":"13"}],"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":[],"market_events":[],"order_book_events":[]}} +{"contract_version":"1","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":"1","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":"execution_price_selected","payload":{"order_id":"demo-order-000000000001","instrument_id":"demo-equity-acme","side":"buy","reference_price":"103","spread_adjustment":"0.06","impact_adjustment":"0.13","final_price":"103.19"}} +{"contract_version":"1","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":"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.5","price":"103.19","notional":"670.735","fee":"0.920735","fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"0.25","quote_amount":"0.25"},{"name":"exchange","kind":"notional_bps","currency":"USD","amount":"0.670735","quote_amount":"0.670735"}],"executed_at":"2026-01-05T14:30:00.000000Z","slice_sequence":"2"}} +{"contract_version":"1","engine_sequence":"14","event_id":"demo-event-000000000014","causation_ids":["demo-event-000000000013"],"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":"-671.655735","position_movement":"6.5","trade_date":"2026-01-05","due_date":"2026-01-06","status":"pending","settled_at":null,"failed_at":null,"failure_reason":null}} +{"contract_version":"1","engine_sequence":"15","event_id":"demo-event-000000000015","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.5","filled_notional":"670.735","status":"cancelled","rejection_reason":null},"reason":"market_ioc"}} +{"contract_version":"1","engine_sequence":"16","event_id":"demo-event-000000000016","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.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-000000000016","updated_event_id":"demo-event-000000000016","created_sequence":"16","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":"1","engine_sequence":"17","event_id":"demo-event-000000000017","causation_ids":["demo-event-000000000010"],"run_id":"demo","recorded_at":"2026-01-05T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"9328.492667","net_market_value":"802.5","long_market_value":"802.5","short_market_value":"0","gross_exposure":"802.5","cost_basis":"761.655735","realized_pnl":"5.148402","unrealized_pnl":"40.844265","equity":"10130.992667","dividend_pnl":"1","execution_fees":"1.420735","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.670735","quote_currency":"USD","quote_amount":"0.670735","base_amount":"0.670735"}],"borrow_fees":"0.25","total_fees":"1.670735","cash_interest":"0.148402","settled_cash":"10000.148402","unsettled_cash":"-671.655735","cash_balances":[{"currency":"USD","amount":"9328.492667","settled_amount":"10000.148402","unsettled_amount":"-671.655735","fx_rate":"1","base_value":"9328.492667","base_settled_value":"10000.148402","base_unsettled_value":"-671.655735","interest":"0.148402","base_interest":"0.148402"}],"positions":[{"instrument_id":"demo-equity-acme","quote_currency":"USD","quantity":"7.5","settled_quantity":"1","unsettled_quantity":"6.5","mark":"107","fx_rate":"1","market_value":"802.5","base_market_value":"802.5","cost_basis":"761.655735","base_cost_basis":"761.655735","realized_pnl":"5","base_realized_pnl":"5","unrealized_pnl":"40.844265","base_unrealized_pnl":"40.844265","dividend_pnl":"1","base_dividend_pnl":"1","execution_fees":"1.420735","base_execution_fees":"1.420735","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.670735","quote_currency":"USD","quote_amount":"0.670735","base_amount":"0.670735"}],"borrow_fees":"0.25","base_borrow_fees":"0.25","total_fees":"1.670735","base_total_fees":"1.670735"}],"margin":{"initial_requirement":"401.25","maintenance_requirement":"200.625","initial_excess":"9729.742667","maintenance_excess":"9930.367667","margin_call":false},"group_exposures":[]}} +{"contract_version":"1","engine_sequence":"18","event_id":"demo-event-000000000018","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":[],"market_events":[],"order_book_events":[]}} +{"contract_version":"1","engine_sequence":"19","event_id":"demo-event-000000000019","causation_ids":["demo-event-000000000018"],"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":"-671.655735","position_movement":"6.5","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":"1","engine_sequence":"20","event_id":"demo-event-000000000020","causation_ids":["demo-event-000000000018"],"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":"9328.492667","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.069218","closing_balance":"9328.561885"}} +{"contract_version":"1","engine_sequence":"21","event_id":"demo-event-000000000021","causation_ids":["demo-event-000000000016","demo-event-000000000018"],"run_id":"demo","recorded_at":"2026-01-06T21:00:02.000000Z","event_type":"execution_price_selected","payload":{"order_id":"demo-order-000000000002","instrument_id":"demo-equity-acme","side":"buy","reference_price":"107","spread_adjustment":"0.06","impact_adjustment":"0.01","final_price":"107.07"}} +{"contract_version":"1","engine_sequence":"22","event_id":"demo-event-000000000022","causation_ids":["demo-event-000000000021"],"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.215","price":"107.07","notional":"237.16005","fee":"0.487161","fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"0.25","quote_amount":"0.25"},{"name":"exchange","kind":"notional_bps","currency":"USD","amount":"0.237161","quote_amount":"0.237161"}],"executed_at":"2026-01-06T14:30:00.000000Z","slice_sequence":"3"}} +{"contract_version":"1","engine_sequence":"23","event_id":"demo-event-000000000023","causation_ids":["demo-event-000000000022"],"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":"-237.647211","position_movement":"2.215","trade_date":"2026-01-06","due_date":"2026-01-07","status":"pending","settled_at":null,"failed_at":null,"failure_reason":null}} +{"contract_version":"1","engine_sequence":"24","event_id":"demo-event-000000000024","causation_ids":["demo-event-000000000018"],"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":"1","engine_sequence":"25","event_id":"demo-event-000000000025","causation_ids":["demo-event-000000000018","demo-event-000000000024"],"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-000000000025","updated_event_id":"demo-event-000000000025","created_sequence":"25","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":"1","engine_sequence":"26","event_id":"demo-event-000000000026","causation_ids":["demo-event-000000000018"],"run_id":"demo","recorded_at":"2026-01-06T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"9090.914674","net_market_value":"1020.075","long_market_value":"1020.075","short_market_value":"0","gross_exposure":"1020.075","cost_basis":"999.302946","realized_pnl":"5.21762","unrealized_pnl":"20.772054","equity":"10110.989674","dividend_pnl":"1","execution_fees":"1.907896","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.907896","quote_currency":"USD","quote_amount":"0.907896","base_amount":"0.907896"}],"borrow_fees":"0.25","total_fees":"2.157896","cash_interest":"0.21762","settled_cash":"9328.561885","unsettled_cash":"-237.647211","cash_balances":[{"currency":"USD","amount":"9090.914674","settled_amount":"9328.561885","unsettled_amount":"-237.647211","fx_rate":"1","base_value":"9090.914674","base_settled_value":"9328.561885","base_unsettled_value":"-237.647211","interest":"0.21762","base_interest":"0.21762"}],"positions":[{"instrument_id":"demo-equity-acme","quote_currency":"USD","quantity":"9.715","settled_quantity":"7.5","unsettled_quantity":"2.215","mark":"105","fx_rate":"1","market_value":"1020.075","base_market_value":"1020.075","cost_basis":"999.302946","base_cost_basis":"999.302946","realized_pnl":"5","base_realized_pnl":"5","unrealized_pnl":"20.772054","base_unrealized_pnl":"20.772054","dividend_pnl":"1","base_dividend_pnl":"1","execution_fees":"1.907896","base_execution_fees":"1.907896","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.907896","quote_currency":"USD","quote_amount":"0.907896","base_amount":"0.907896"}],"borrow_fees":"0.25","base_borrow_fees":"0.25","total_fees":"2.157896","base_total_fees":"2.157896"}],"margin":{"initial_requirement":"510.0375","maintenance_requirement":"255.01875","initial_excess":"9600.952174","maintenance_excess":"9855.970924","margin_call":false},"group_exposures":[]}} +{"contract_version":"1","engine_sequence":"27","event_id":"demo-event-000000000027","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":[],"market_events":[],"order_book_events":[]}} +{"contract_version":"1","engine_sequence":"28","event_id":"demo-event-000000000028","causation_ids":["demo-event-000000000027"],"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":"-237.647211","position_movement":"2.215","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":"1","engine_sequence":"29","event_id":"demo-event-000000000029","causation_ids":["demo-event-000000000027"],"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.914674","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.067455","closing_balance":"9090.982129"}} +{"contract_version":"1","engine_sequence":"30","event_id":"demo-event-000000000030","causation_ids":["demo-event-000000000025","demo-event-000000000027"],"run_id":"demo","recorded_at":"2026-01-07T21:00:02.000000Z","event_type":"execution_price_selected","payload":{"order_id":"demo-order-000000000003","instrument_id":"demo-equity-acme","side":"sell","reference_price":"105","spread_adjustment":"0.06","impact_adjustment":"0.02","final_price":"104.92"}} +{"contract_version":"1","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":"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":"104.92","notional":"756.9978","fee":"1","fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"0.25","quote_amount":"0.25"},{"name":"exchange","kind":"notional_bps","currency":"USD","amount":"0.756998","quote_amount":"0.756998"},{"name":"maximum_adjustment","kind":"maximum_adjustment","currency":"USD","amount":"-0.006998","quote_amount":"-0.006998"}],"executed_at":"2026-01-07T14:30:00.000000Z","slice_sequence":"4"}} +{"contract_version":"1","engine_sequence":"32","event_id":"demo-event-000000000032","causation_ids":["demo-event-000000000031"],"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":"755.9978","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":"1","engine_sequence":"33","event_id":"demo-event-000000000033","causation_ids":["demo-event-000000000027"],"run_id":"demo","recorded_at":"2026-01-07T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"9846.979929","net_market_value":"265","long_market_value":"265","short_market_value":"0","gross_exposure":"265","cost_basis":"257.154644","realized_pnl":"19.134573","unrealized_pnl":"7.845356","equity":"10111.979929","dividend_pnl":"1","execution_fees":"2.907896","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.664894","quote_currency":"USD","quote_amount":"1.664894","base_amount":"1.664894"},{"name":"maximum_adjustment","kind":"maximum_adjustment","currency":"USD","amount":"-0.006998","quote_currency":"USD","quote_amount":"-0.006998","base_amount":"-0.006998"}],"borrow_fees":"0.25","total_fees":"3.157896","cash_interest":"0.285075","settled_cash":"9090.982129","unsettled_cash":"755.9978","cash_balances":[{"currency":"USD","amount":"9846.979929","settled_amount":"9090.982129","unsettled_amount":"755.9978","fx_rate":"1","base_value":"9846.979929","base_settled_value":"9090.982129","base_unsettled_value":"755.9978","interest":"0.285075","base_interest":"0.285075"}],"positions":[{"instrument_id":"demo-equity-acme","quote_currency":"USD","quantity":"2.5","settled_quantity":"9.715","unsettled_quantity":"-7.215","mark":"106","fx_rate":"1","market_value":"265","base_market_value":"265","cost_basis":"257.154644","base_cost_basis":"257.154644","realized_pnl":"18.849498","base_realized_pnl":"18.849498","unrealized_pnl":"7.845356","base_unrealized_pnl":"7.845356","dividend_pnl":"1","base_dividend_pnl":"1","execution_fees":"2.907896","base_execution_fees":"2.907896","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.664894","quote_currency":"USD","quote_amount":"1.664894","base_amount":"1.664894"},{"name":"maximum_adjustment","kind":"maximum_adjustment","currency":"USD","amount":"-0.006998","quote_currency":"USD","quote_amount":"-0.006998","base_amount":"-0.006998"}],"borrow_fees":"0.25","base_borrow_fees":"0.25","total_fees":"3.157896","base_total_fees":"3.157896"}],"margin":{"initial_requirement":"132.5","maintenance_requirement":"66.25","initial_excess":"9979.479929","maintenance_excess":"10045.729929","margin_call":false},"group_exposures":[]}} +{"contract_version":"1","engine_sequence":"34","event_id":"demo-event-000000000034","causation_ids":["demo-event-000000000033"],"run_id":"demo","recorded_at":"2026-01-07T21:00:02.000000Z","event_type":"run_completed","payload":{"scenario_sha256":"e2227af76072fab8151c3e1bd86f401293d16a736e32040efdaf8761cd397574","execution_model":"completed_bar_adverse_touch_v1","valuation":{"base_currency":"USD","cash":"9846.979929","net_market_value":"265","long_market_value":"265","short_market_value":"0","gross_exposure":"265","cost_basis":"257.154644","realized_pnl":"19.134573","unrealized_pnl":"7.845356","equity":"10111.979929","dividend_pnl":"1","execution_fees":"2.907896","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.664894","quote_currency":"USD","quote_amount":"1.664894","base_amount":"1.664894"},{"name":"maximum_adjustment","kind":"maximum_adjustment","currency":"USD","amount":"-0.006998","quote_currency":"USD","quote_amount":"-0.006998","base_amount":"-0.006998"}],"borrow_fees":"0.25","total_fees":"3.157896","cash_interest":"0.285075","settled_cash":"9090.982129","unsettled_cash":"755.9978","cash_balances":[{"currency":"USD","amount":"9846.979929","settled_amount":"9090.982129","unsettled_amount":"755.9978","fx_rate":"1","base_value":"9846.979929","base_settled_value":"9090.982129","base_unsettled_value":"755.9978","interest":"0.285075","base_interest":"0.285075"}],"positions":[{"instrument_id":"demo-equity-acme","quote_currency":"USD","quantity":"2.5","settled_quantity":"9.715","unsettled_quantity":"-7.215","mark":"106","fx_rate":"1","market_value":"265","base_market_value":"265","cost_basis":"257.154644","base_cost_basis":"257.154644","realized_pnl":"18.849498","base_realized_pnl":"18.849498","unrealized_pnl":"7.845356","base_unrealized_pnl":"7.845356","dividend_pnl":"1","base_dividend_pnl":"1","execution_fees":"2.907896","base_execution_fees":"2.907896","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.664894","quote_currency":"USD","quote_amount":"1.664894","base_amount":"1.664894"},{"name":"maximum_adjustment","kind":"maximum_adjustment","currency":"USD","amount":"-0.006998","quote_currency":"USD","quote_amount":"-0.006998","base_amount":"-0.006998"}],"borrow_fees":"0.25","base_borrow_fees":"0.25","total_fees":"3.157896","base_total_fees":"3.157896"}],"margin":{"initial_requirement":"132.5","maintenance_requirement":"66.25","initial_excess":"9979.479929","maintenance_excess":"10045.729929","margin_call":false},"group_exposures":[]},"order_counts":{"total":3,"active":0,"filled":2,"rejected":0,"cancelled":1}}} diff --git a/contracts/v1/fixtures/demo.scenario.json b/contracts/v1/fixtures/demo.scenario.json index fd0da5d..db90199 100644 --- a/contracts/v1/fixtures/demo.scenario.json +++ b/contracts/v1/fixtures/demo.scenario.json @@ -6,24 +6,205 @@ }, "run_id": "demo", "base_currency": "USD", - "initial_cash": "10000", + "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": "1" + "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_order_quantity": "1000", - "max_position": "1000" + "max_gross_exposure": "1000000", + "max_leverage": "2", + "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": { - "participation_bps": 5000, - "fixed_fee": "0.25", - "fee_bps": 10 + "model": "completed_bar_adverse_touch_v1", + "configuration": { + "version": "1", + "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" + } + ] + } + ], + "spread_model": { + "model": "fixed_half_spread_v1", + "half_spread_bps": 5 + }, + "impact_model": { + "model": "linear_participation_v1", + "coefficient_bps": 25, + "missing_volume_policy": "reject" + } + } }, "max_internal_events": 1000, "schedule": [ @@ -42,7 +223,10 @@ { "type": "emit_metric", "name": "desired_weight", - "value": "0.1" + "value": { "type": "numeric", "value": "0.1" }, + "unit": "ratio", + "dimensions": { "source": "strategy", "instrument": "demo-equity-acme" }, + "aggregation": "last" } ] }, @@ -54,7 +238,7 @@ "targets": [ { "instrument_id": "demo-equity-acme", - "quantity": "2" + "quantity": "2.5" } ] } @@ -77,7 +261,35 @@ "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": [], + "market_events": [], + "order_book_events": [] }, { "slice_sequence": "2", @@ -92,9 +304,37 @@ "high": "108", "low": "102", "close": "107", - "volume": "12" + "volume": "13" } - ] + ], + "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": [], + "market_events": [], + "order_book_events": [] }, { "slice_sequence": "3", @@ -111,7 +351,35 @@ "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": [], + "market_events": [], + "order_book_events": [] }, { "slice_sequence": "4", @@ -128,7 +396,72 @@ "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": [], + "market_events": [], + "order_book_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/v1/fixtures/demo.scenario.jsonl b/contracts/v1/fixtures/demo.scenario.jsonl index 6aa3248..bcb0aa8 100644 --- a/contracts/v1/fixtures/demo.scenario.jsonl +++ b/contracts/v1/fixtures/demo.scenario.jsonl @@ -1,6 +1,6 @@ -{"contract_version":"1","payload":{"base_currency":"USD","execution":{"fee_bps":10,"fixed_fee":"0.25","participation_bps":5000},"initial_cash":"10000","instruments":[{"instrument_id":"demo-equity-acme","lot_size":"1","quote_currency":"USD","symbol":"ACME","tick_size":"0.01"}],"max_internal_events":1000,"metadata":{"producer":"trading-engine-demo","purpose":"deterministic conformance fixture"},"risk":{"max_order_quantity":"1000","max_position":"1000"},"run_id":"demo"},"record_type":"scenario_header","scenario_sequence":"1"} -{"contract_version":"1","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"}],"end_at":"2026-01-02T21:00:00Z","received_at":"2026-01-02T21:00:02Z","slice_sequence":"1","start_at":"2026-01-02T14:30:00Z"}},"record_type":"market_slice","scenario_sequence":"2"} -{"contract_version":"1","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"}],"end_at":"2026-01-05T21:00:00Z","received_at":"2026-01-05T21:00:02Z","slice_sequence":"2","start_at":"2026-01-05T14:30:00Z"}},"record_type":"market_slice","scenario_sequence":"3"} -{"contract_version":"1","payload":{"intents":[{"targets":[{"instrument_id":"demo-equity-acme","quantity":"2"}],"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"}],"end_at":"2026-01-06T21:00:00Z","received_at":"2026-01-06T21:00:02Z","slice_sequence":"3","start_at":"2026-01-06T14:30:00Z"}},"record_type":"market_slice","scenario_sequence":"4"} -{"contract_version":"1","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"}],"end_at":"2026-01-07T21:00:00Z","received_at":"2026-01-07T21:00:02Z","slice_sequence":"4","start_at":"2026-01-07T14:30:00Z"}},"record_type":"market_slice","scenario_sequence":"5"} +{"contract_version":"1","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","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_adverse_touch_v1","configuration":{"version":"1","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"}]}],"spread_model":{"model":"fixed_half_spread_v1","half_spread_bps":5},"impact_model":{"model":"linear_participation_v1","coefficient_bps":25,"missing_volume_policy":"reject"}}},"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":"1","payload":{"intents":[{"targets":[{"instrument_id":"demo-equity-acme","weight":"0.1"}],"type":"target_weights"},{"aggregation":"last","dimensions":{"instrument":"demo-equity-acme","source":"strategy"},"name":"desired_weight","type":"emit_metric","unit":"ratio","value":{"type":"numeric","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":[],"market_events":[],"order_book_events":[]}},"record_type":"market_slice","scenario_sequence":"2"} +{"contract_version":"1","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":"13"}],"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":[],"market_events":[],"order_book_events":[]}},"record_type":"market_slice","scenario_sequence":"3"} +{"contract_version":"1","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":[],"market_events":[],"order_book_events":[]}},"record_type":"market_slice","scenario_sequence":"4"} +{"contract_version":"1","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":[],"market_events":[],"order_book_events":[]}},"record_type":"market_slice","scenario_sequence":"5"} {"contract_version":"1","payload":{"slice_count":"4"},"record_type":"scenario_end","scenario_sequence":"6"} diff --git a/contracts/v1/fixtures/fill-clipped.journal.jsonl b/contracts/v1/fixtures/fill-clipped.journal.jsonl new file mode 100644 index 0000000..7318059 --- /dev/null +++ b/contracts/v1/fixtures/fill-clipped.journal.jsonl @@ -0,0 +1,13 @@ +{"contract_version":"1","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":"49451cf495ed00cdcc5330a62814b9b6adeb6e4fc844e74a5ae35f5cbf91c893","execution_model":"completed_bar_v1"}} +{"contract_version":"1","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","execution_fee_components":[],"borrow_fees":"0","total_fees":"0","cash_interest":"0","settled_cash":"550","unsettled_cash":"0","cash_balances":[{"currency":"USD","amount":"550","settled_amount":"550","unsettled_amount":"0","fx_rate":"1","base_value":"550","base_settled_value":"550","base_unsettled_value":"0","interest":"0","base_interest":"0"}],"positions":[],"margin":{"initial_requirement":"0","maintenance_requirement":"0","initial_excess":"550","maintenance_excess":"550","margin_call":false},"group_exposures":[]}}} +{"contract_version":"1","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","execution_fee_components":[],"borrow_fees":"0","total_fees":"0","cash_interest":"0","settled_cash":"550","unsettled_cash":"0","cash_balances":[{"currency":"USD","amount":"550","settled_amount":"550","unsettled_amount":"0","fx_rate":"1","base_value":"550","base_settled_value":"550","base_unsettled_value":"0","interest":"0","base_interest":"0"}],"positions":[],"margin":{"initial_requirement":"0","maintenance_requirement":"0","initial_excess":"550","maintenance_excess":"550","margin_call":false},"group_exposures":[]}} +{"contract_version":"1","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":[],"market_events":[],"order_book_events":[]}} +{"contract_version":"1","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":"1","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":"1","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","execution_fee_components":[],"borrow_fees":"0","total_fees":"0","cash_interest":"0.004081","settled_cash":"550.004081","unsettled_cash":"0","cash_balances":[{"currency":"USD","amount":"550.004081","settled_amount":"550.004081","unsettled_amount":"0","fx_rate":"1","base_value":"550.004081","base_settled_value":"550.004081","base_unsettled_value":"0","interest":"0.004081","base_interest":"0.004081"}],"positions":[{"instrument_id":"clip-equity","quote_currency":"USD","quantity":"0","settled_quantity":"0","unsettled_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","execution_fee_components":[],"borrow_fees":"0","base_borrow_fees":"0","total_fees":"0","base_total_fees":"0"}],"margin":{"initial_requirement":"0","maintenance_requirement":"0","initial_excess":"550.004081","maintenance_excess":"550.004081","margin_call":false},"group_exposures":[]}} +{"contract_version":"1","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":[],"market_events":[],"order_book_events":[]}} +{"contract_version":"1","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":"1","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":"1","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":"1","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","execution_fee_components":[],"borrow_fees":"0","total_fees":"0","cash_interest":"0.008162","settled_cash":"550.008162","unsettled_cash":"0","cash_balances":[{"currency":"USD","amount":"550.008162","settled_amount":"550.008162","unsettled_amount":"0","fx_rate":"1","base_value":"550.008162","base_settled_value":"550.008162","base_unsettled_value":"0","interest":"0.008162","base_interest":"0.008162"}],"positions":[{"instrument_id":"clip-equity","quote_currency":"USD","quantity":"0","settled_quantity":"0","unsettled_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","execution_fee_components":[],"borrow_fees":"0","base_borrow_fees":"0","total_fees":"0","base_total_fees":"0"}],"margin":{"initial_requirement":"0","maintenance_requirement":"0","initial_excess":"550.008162","maintenance_excess":"550.008162","margin_call":false},"group_exposures":[]}} +{"contract_version":"1","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":"49451cf495ed00cdcc5330a62814b9b6adeb6e4fc844e74a5ae35f5cbf91c893","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","execution_fee_components":[],"borrow_fees":"0","total_fees":"0","cash_interest":"0.008162","settled_cash":"550.008162","unsettled_cash":"0","cash_balances":[{"currency":"USD","amount":"550.008162","settled_amount":"550.008162","unsettled_amount":"0","fx_rate":"1","base_value":"550.008162","base_settled_value":"550.008162","base_unsettled_value":"0","interest":"0.008162","base_interest":"0.008162"}],"positions":[{"instrument_id":"clip-equity","quote_currency":"USD","quantity":"0","settled_quantity":"0","unsettled_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","execution_fee_components":[],"borrow_fees":"0","base_borrow_fees":"0","total_fees":"0","base_total_fees":"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/v1/fixtures/fill-clipped.scenario.json b/contracts/v1/fixtures/fill-clipped.scenario.json new file mode 100644 index 0000000..e4edda4 --- /dev/null +++ b/contracts/v1/fixtures/fill-clipped.scenario.json @@ -0,0 +1,272 @@ +{ + "contract_version": "1", + "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", + "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": "1", + "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": [], + "market_events": [], + "order_book_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": [], + "market_events": [], + "order_book_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/v1/fixtures/order-book.journal.jsonl b/contracts/v1/fixtures/order-book.journal.jsonl new file mode 100644 index 0000000..f016002 --- /dev/null +++ b/contracts/v1/fixtures/order-book.journal.jsonl @@ -0,0 +1,15 @@ +{"contract_version":"1","engine_sequence":"1","event_id":"order-book-event-000000000001","causation_ids":[],"run_id":"order-book","recorded_at":"2026-02-02T21:00:02.000000Z","event_type":"run_started","payload":{"scenario_sha256":"d1e8ead9f40facf6b095a785e56ce0dc6f40b8e37cfff91e8de87787bff38c8c","execution_model":"order_book_v1"}} +{"contract_version":"1","engine_sequence":"2","event_id":"order-book-event-000000000002","causation_ids":["order-book-event-000000000001"],"run_id":"order-book","recorded_at":"2026-02-02T21:00:02.000000Z","event_type":"initial_state","payload":{"portfolio":{"cash":[{"currency":"USD","amount":"2000"}],"positions":[],"marks":[],"fx_rates":[{"currency":"USD","rate":"1"}]},"valuation":{"base_currency":"USD","cash":"2000","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":"2000","dividend_pnl":"0","execution_fees":"0","execution_fee_components":[],"borrow_fees":"0","total_fees":"0","cash_interest":"0","settled_cash":"2000","unsettled_cash":"0","cash_balances":[{"currency":"USD","amount":"2000","settled_amount":"2000","unsettled_amount":"0","fx_rate":"1","base_value":"2000","base_settled_value":"2000","base_unsettled_value":"0","interest":"0","base_interest":"0"}],"positions":[],"margin":{"initial_requirement":"0","maintenance_requirement":"0","initial_excess":"2000","maintenance_excess":"2000","margin_call":false},"group_exposures":[]}}} +{"contract_version":"1","engine_sequence":"3","event_id":"order-book-event-000000000003","causation_ids":["order-book-event-000000000002"],"run_id":"order-book","recorded_at":"2026-02-02T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"2000","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":"2000","dividend_pnl":"0","execution_fees":"0","execution_fee_components":[],"borrow_fees":"0","total_fees":"0","cash_interest":"0","settled_cash":"2000","unsettled_cash":"0","cash_balances":[{"currency":"USD","amount":"2000","settled_amount":"2000","unsettled_amount":"0","fx_rate":"1","base_value":"2000","base_settled_value":"2000","base_unsettled_value":"0","interest":"0","base_interest":"0"}],"positions":[],"margin":{"initial_requirement":"0","maintenance_requirement":"0","initial_excess":"2000","maintenance_excess":"2000","margin_call":false},"group_exposures":[]}} +{"contract_version":"1","engine_sequence":"4","event_id":"order-book-event-000000000004","causation_ids":[],"run_id":"order-book","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":[],"market_events":[],"order_book_events":[{"type":"snapshot","instrument_id":"clip-equity","event_at":"2026-02-02T14:31:00.000000Z","available_at":"2026-02-02T14:31:01.000000Z","received_at":"2026-02-02T14:31:02.000000Z","ingest_sequence":"1","book_sequence":"1","bids":[{"price":"49","quantity":"20"}],"asks":[{"price":"51","quantity":"20"}]}]}} +{"contract_version":"1","engine_sequence":"5","event_id":"order-book-event-000000000005","causation_ids":["order-book-event-000000000004"],"run_id":"order-book","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":"2000","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.01484","closing_balance":"2000.01484"}} +{"contract_version":"1","engine_sequence":"6","event_id":"order-book-event-000000000006","causation_ids":["order-book-event-000000000004"],"run_id":"order-book","recorded_at":"2026-02-02T21:00:02.000000Z","event_type":"order_accepted","payload":{"order_id":"order-book-order-000000000001","instrument_id":"clip-equity","side":"buy","quantity":"10","order_kind":"limit","trigger_price":null,"limit_price":"100","time_in_force":"gtc","venue_id":null,"calendar_id":null,"expires_at":null,"origin":"direct","created_event_id":"order-book-event-000000000006","updated_event_id":"order-book-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":"1","engine_sequence":"7","event_id":"order-book-event-000000000007","causation_ids":["order-book-event-000000000004"],"run_id":"order-book","recorded_at":"2026-02-02T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"2000.01484","net_market_value":"0","long_market_value":"0","short_market_value":"0","gross_exposure":"0","cost_basis":"0","realized_pnl":"0.01484","unrealized_pnl":"0","equity":"2000.01484","dividend_pnl":"0","execution_fees":"0","execution_fee_components":[],"borrow_fees":"0","total_fees":"0","cash_interest":"0.01484","settled_cash":"2000.01484","unsettled_cash":"0","cash_balances":[{"currency":"USD","amount":"2000.01484","settled_amount":"2000.01484","unsettled_amount":"0","fx_rate":"1","base_value":"2000.01484","base_settled_value":"2000.01484","base_unsettled_value":"0","interest":"0.01484","base_interest":"0.01484"}],"positions":[{"instrument_id":"clip-equity","quote_currency":"USD","quantity":"0","settled_quantity":"0","unsettled_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","execution_fee_components":[],"borrow_fees":"0","base_borrow_fees":"0","total_fees":"0","base_total_fees":"0"}],"margin":{"initial_requirement":"0","maintenance_requirement":"0","initial_excess":"2000.01484","maintenance_excess":"2000.01484","margin_call":false},"group_exposures":[]}} +{"contract_version":"1","engine_sequence":"8","event_id":"order-book-event-000000000008","causation_ids":[],"run_id":"order-book","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":[],"market_events":[],"order_book_events":[{"type":"snapshot","instrument_id":"clip-equity","event_at":"2026-02-03T14:31:00.000000Z","available_at":"2026-02-03T14:31:01.000000Z","received_at":"2026-02-03T14:31:02.000000Z","ingest_sequence":"1","book_sequence":"1","bids":[{"price":"100","quantity":"5"}],"asks":[{"price":"101","quantity":"20"}]},{"type":"set","instrument_id":"clip-equity","event_at":"2026-02-03T14:32:00.000000Z","available_at":"2026-02-03T14:32:01.000000Z","received_at":"2026-02-03T14:32:02.000000Z","ingest_sequence":"2","book_sequence":"2","side":"bid","price":"100","quantity":"3"},{"type":"trade","instrument_id":"clip-equity","event_at":"2026-02-03T14:33:00.000000Z","available_at":"2026-02-03T14:33:01.000000Z","received_at":"2026-02-03T14:33:02.000000Z","ingest_sequence":"3","book_sequence":"3","price":"100","quantity":"3","aggressor_side":"sell"},{"type":"set","instrument_id":"clip-equity","event_at":"2026-02-03T14:34:00.000000Z","available_at":"2026-02-03T14:34:01.000000Z","received_at":"2026-02-03T14:34:02.000000Z","ingest_sequence":"4","book_sequence":"4","side":"bid","price":"100","quantity":"10"},{"type":"trade","instrument_id":"clip-equity","event_at":"2026-02-03T14:35:00.000000Z","available_at":"2026-02-03T14:35:01.000000Z","received_at":"2026-02-03T14:35:02.000000Z","ingest_sequence":"5","book_sequence":"5","price":"100","quantity":"4","aggressor_side":"sell"},{"type":"trade","instrument_id":"clip-equity","event_at":"2026-02-03T14:36:00.000000Z","available_at":"2026-02-03T14:36:01.000000Z","received_at":"2026-02-03T14:36:02.000000Z","ingest_sequence":"6","book_sequence":"6","price":"100","quantity":"6","aggressor_side":"sell"}]}} +{"contract_version":"1","engine_sequence":"9","event_id":"order-book-event-000000000009","causation_ids":["order-book-event-000000000008"],"run_id":"order-book","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":"2000.01484","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.01484","closing_balance":"2000.02968"}} +{"contract_version":"1","engine_sequence":"10","event_id":"order-book-event-000000000010","causation_ids":["order-book-event-000000000006","order-book-event-000000000008"],"run_id":"order-book","recorded_at":"2026-02-03T21:00:02.000000Z","event_type":"fill_applied","payload":{"fill_id":"order-book-fill-000000000001","order_id":"order-book-order-000000000001","instrument_id":"clip-equity","quote_currency":"USD","side":"buy","quantity":"4","price":"100","notional":"400","fee":"10","fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"10","quote_amount":"10"}],"executed_at":"2026-02-03T14:35:00.000000Z","slice_sequence":"2"}} +{"contract_version":"1","engine_sequence":"11","event_id":"order-book-event-000000000011","causation_ids":["order-book-event-000000000010"],"run_id":"order-book","recorded_at":"2026-02-03T21:00:02.000000Z","event_type":"settlement_instruction_created","payload":{"instruction_id":"order-book-fill-000000000001-settlement","fill_id":"order-book-fill-000000000001","instrument_id":"clip-equity","currency":"USD","cash_movement":"-410","position_movement":"4","trade_date":"2026-02-03","due_date":"2026-02-04","status":"pending","settled_at":null,"failed_at":null,"failure_reason":null}} +{"contract_version":"1","engine_sequence":"12","event_id":"order-book-event-000000000012","causation_ids":["order-book-event-000000000006","order-book-event-000000000008"],"run_id":"order-book","recorded_at":"2026-02-03T21:00:02.000000Z","event_type":"fill_applied","payload":{"fill_id":"order-book-fill-000000000002","order_id":"order-book-order-000000000001","instrument_id":"clip-equity","quote_currency":"USD","side":"buy","quantity":"6","price":"100","notional":"600","fee":"10","fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"10","quote_amount":"10"}],"executed_at":"2026-02-03T14:36:00.000000Z","slice_sequence":"2"}} +{"contract_version":"1","engine_sequence":"13","event_id":"order-book-event-000000000013","causation_ids":["order-book-event-000000000012"],"run_id":"order-book","recorded_at":"2026-02-03T21:00:02.000000Z","event_type":"settlement_instruction_created","payload":{"instruction_id":"order-book-fill-000000000002-settlement","fill_id":"order-book-fill-000000000002","instrument_id":"clip-equity","currency":"USD","cash_movement":"-610","position_movement":"6","trade_date":"2026-02-03","due_date":"2026-02-04","status":"pending","settled_at":null,"failed_at":null,"failure_reason":null}} +{"contract_version":"1","engine_sequence":"14","event_id":"order-book-event-000000000014","causation_ids":["order-book-event-000000000008"],"run_id":"order-book","recorded_at":"2026-02-03T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"980.02968","net_market_value":"1000","long_market_value":"1000","short_market_value":"0","gross_exposure":"1000","cost_basis":"1020","realized_pnl":"0.02968","unrealized_pnl":"-20","equity":"1980.02968","dividend_pnl":"0","execution_fees":"20","execution_fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"20","quote_currency":"USD","quote_amount":"20","base_amount":"20"}],"borrow_fees":"0","total_fees":"20","cash_interest":"0.02968","settled_cash":"2000.02968","unsettled_cash":"-1020","cash_balances":[{"currency":"USD","amount":"980.02968","settled_amount":"2000.02968","unsettled_amount":"-1020","fx_rate":"1","base_value":"980.02968","base_settled_value":"2000.02968","base_unsettled_value":"-1020","interest":"0.02968","base_interest":"0.02968"}],"positions":[{"instrument_id":"clip-equity","quote_currency":"USD","quantity":"10","settled_quantity":"0","unsettled_quantity":"10","mark":"100","fx_rate":"1","market_value":"1000","base_market_value":"1000","cost_basis":"1020","base_cost_basis":"1020","realized_pnl":"0","base_realized_pnl":"0","unrealized_pnl":"-20","base_unrealized_pnl":"-20","dividend_pnl":"0","base_dividend_pnl":"0","execution_fees":"20","base_execution_fees":"20","execution_fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"20","quote_currency":"USD","quote_amount":"20","base_amount":"20"}],"borrow_fees":"0","base_borrow_fees":"0","total_fees":"20","base_total_fees":"20"}],"margin":{"initial_requirement":"500","maintenance_requirement":"250","initial_excess":"1480.02968","maintenance_excess":"1730.02968","margin_call":false},"group_exposures":[]}} +{"contract_version":"1","engine_sequence":"15","event_id":"order-book-event-000000000015","causation_ids":["order-book-event-000000000014"],"run_id":"order-book","recorded_at":"2026-02-03T21:00:02.000000Z","event_type":"run_completed","payload":{"scenario_sha256":"d1e8ead9f40facf6b095a785e56ce0dc6f40b8e37cfff91e8de87787bff38c8c","execution_model":"order_book_v1","valuation":{"base_currency":"USD","cash":"980.02968","net_market_value":"1000","long_market_value":"1000","short_market_value":"0","gross_exposure":"1000","cost_basis":"1020","realized_pnl":"0.02968","unrealized_pnl":"-20","equity":"1980.02968","dividend_pnl":"0","execution_fees":"20","execution_fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"20","quote_currency":"USD","quote_amount":"20","base_amount":"20"}],"borrow_fees":"0","total_fees":"20","cash_interest":"0.02968","settled_cash":"2000.02968","unsettled_cash":"-1020","cash_balances":[{"currency":"USD","amount":"980.02968","settled_amount":"2000.02968","unsettled_amount":"-1020","fx_rate":"1","base_value":"980.02968","base_settled_value":"2000.02968","base_unsettled_value":"-1020","interest":"0.02968","base_interest":"0.02968"}],"positions":[{"instrument_id":"clip-equity","quote_currency":"USD","quantity":"10","settled_quantity":"0","unsettled_quantity":"10","mark":"100","fx_rate":"1","market_value":"1000","base_market_value":"1000","cost_basis":"1020","base_cost_basis":"1020","realized_pnl":"0","base_realized_pnl":"0","unrealized_pnl":"-20","base_unrealized_pnl":"-20","dividend_pnl":"0","base_dividend_pnl":"0","execution_fees":"20","base_execution_fees":"20","execution_fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"20","quote_currency":"USD","quote_amount":"20","base_amount":"20"}],"borrow_fees":"0","base_borrow_fees":"0","total_fees":"20","base_total_fees":"20"}],"margin":{"initial_requirement":"500","maintenance_requirement":"250","initial_excess":"1480.02968","maintenance_excess":"1730.02968","margin_call":false},"group_exposures":[]},"order_counts":{"total":1,"active":0,"filled":1,"rejected":0,"cancelled":0}}} diff --git a/contracts/v1/fixtures/order-book.scenario.json b/contracts/v1/fixtures/order-book.scenario.json new file mode 100644 index 0000000..f107f4e --- /dev/null +++ b/contracts/v1/fixtures/order-book.scenario.json @@ -0,0 +1,377 @@ +{ + "contract_version": "1", + "metadata": { + "producer": "trading-engine", + "purpose": "bounded order-book replay conformance fixture" + }, + "run_id": "order-book", + "base_currency": "USD", + "initial_portfolio": { + "cash": [ + { + "currency": "USD", + "amount": "2000" + } + ], + "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", + "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": "order_book_v1", + "configuration": { + "version": "1", + "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_depth_levels": 10 + } + }, + "max_internal_events": 1000, + "schedule": [ + { + "after_slice_sequence": "1", + "intents": [ + { + "type": "submit_order", + "instrument_id": "clip-equity", + "side": "buy", + "quantity": "10", + "order_kind": "limit", + "trigger_price": null, + "limit_price": "100", + "time_in_force": "gtc", + "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": [], + "market_events": [], + "order_book_events": [ + { + "type": "snapshot", + "instrument_id": "clip-equity", + "event_at": "2026-02-02T14:31:00Z", + "available_at": "2026-02-02T14:31:01Z", + "received_at": "2026-02-02T14:31:02Z", + "ingest_sequence": "1", + "book_sequence": "1", + "bids": [ + { + "price": "49", + "quantity": "20" + } + ], + "asks": [ + { + "price": "51", + "quantity": "20" + } + ] + } + ] + }, + { + "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": [], + "market_events": [], + "order_book_events": [ + { + "type": "snapshot", + "instrument_id": "clip-equity", + "event_at": "2026-02-03T14:31:00Z", + "available_at": "2026-02-03T14:31:01Z", + "received_at": "2026-02-03T14:31:02Z", + "ingest_sequence": "1", + "book_sequence": "1", + "bids": [ + { + "price": "100", + "quantity": "5" + } + ], + "asks": [ + { + "price": "101", + "quantity": "20" + } + ] + }, + { + "type": "set", + "instrument_id": "clip-equity", + "event_at": "2026-02-03T14:32:00Z", + "available_at": "2026-02-03T14:32:01Z", + "received_at": "2026-02-03T14:32:02Z", + "ingest_sequence": "2", + "book_sequence": "2", + "side": "bid", + "price": "100", + "quantity": "3" + }, + { + "type": "trade", + "instrument_id": "clip-equity", + "event_at": "2026-02-03T14:33:00Z", + "available_at": "2026-02-03T14:33:01Z", + "received_at": "2026-02-03T14:33:02Z", + "ingest_sequence": "3", + "book_sequence": "3", + "price": "100", + "quantity": "3", + "aggressor_side": "sell" + }, + { + "type": "set", + "instrument_id": "clip-equity", + "event_at": "2026-02-03T14:34:00Z", + "available_at": "2026-02-03T14:34:01Z", + "received_at": "2026-02-03T14:34:02Z", + "ingest_sequence": "4", + "book_sequence": "4", + "side": "bid", + "price": "100", + "quantity": "10" + }, + { + "type": "trade", + "instrument_id": "clip-equity", + "event_at": "2026-02-03T14:35:00Z", + "available_at": "2026-02-03T14:35:01Z", + "received_at": "2026-02-03T14:35:02Z", + "ingest_sequence": "5", + "book_sequence": "5", + "price": "100", + "quantity": "4", + "aggressor_side": "sell" + }, + { + "type": "trade", + "instrument_id": "clip-equity", + "event_at": "2026-02-03T14:36:00Z", + "available_at": "2026-02-03T14:36:01Z", + "received_at": "2026-02-03T14:36:02Z", + "ingest_sequence": "6", + "book_sequence": "6", + "price": "100", + "quantity": "6", + "aggressor_side": "sell" + } + ] + } + ], + "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/v1/fixtures/order-book.scenario.jsonl b/contracts/v1/fixtures/order-book.scenario.jsonl new file mode 100644 index 0000000..0ef9d37 --- /dev/null +++ b/contracts/v1/fixtures/order-book.scenario.jsonl @@ -0,0 +1,4 @@ +{"contract_version":"1","scenario_sequence":"1","record_type":"scenario_header","payload":{"metadata":{"producer":"trading-engine","purpose":"bounded order-book replay conformance fixture"},"run_id":"order-book","base_currency":"USD","initial_portfolio":{"cash":[{"currency":"USD","amount":"2000"}],"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","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":"order_book_v1","configuration":{"version":"1","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_depth_levels":10}},"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":"clip-equity","calendar_id":"default-settlement","lag_business_days":1}]}}} +{"contract_version":"1","scenario_sequence":"2","record_type":"market_slice","payload":{"intents":[{"type":"submit_order","instrument_id":"clip-equity","side":"buy","quantity":"10","order_kind":"limit","trigger_price":null,"limit_price":"100","time_in_force":"gtc","venue_id":null,"calendar_id":null,"expires_at":null}],"market_slice":{"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":[],"market_events":[],"order_book_events":[{"type":"snapshot","instrument_id":"clip-equity","event_at":"2026-02-02T14:31:00Z","available_at":"2026-02-02T14:31:01Z","received_at":"2026-02-02T14:31:02Z","ingest_sequence":"1","book_sequence":"1","bids":[{"price":"49","quantity":"20"}],"asks":[{"price":"51","quantity":"20"}]}]}}} +{"contract_version":"1","scenario_sequence":"3","record_type":"market_slice","payload":{"intents":[],"market_slice":{"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":[],"market_events":[],"order_book_events":[{"type":"snapshot","instrument_id":"clip-equity","event_at":"2026-02-03T14:31:00Z","available_at":"2026-02-03T14:31:01Z","received_at":"2026-02-03T14:31:02Z","ingest_sequence":"1","book_sequence":"1","bids":[{"price":"100","quantity":"5"}],"asks":[{"price":"101","quantity":"20"}]},{"type":"set","instrument_id":"clip-equity","event_at":"2026-02-03T14:32:00Z","available_at":"2026-02-03T14:32:01Z","received_at":"2026-02-03T14:32:02Z","ingest_sequence":"2","book_sequence":"2","side":"bid","price":"100","quantity":"3"},{"type":"trade","instrument_id":"clip-equity","event_at":"2026-02-03T14:33:00Z","available_at":"2026-02-03T14:33:01Z","received_at":"2026-02-03T14:33:02Z","ingest_sequence":"3","book_sequence":"3","price":"100","quantity":"3","aggressor_side":"sell"},{"type":"set","instrument_id":"clip-equity","event_at":"2026-02-03T14:34:00Z","available_at":"2026-02-03T14:34:01Z","received_at":"2026-02-03T14:34:02Z","ingest_sequence":"4","book_sequence":"4","side":"bid","price":"100","quantity":"10"},{"type":"trade","instrument_id":"clip-equity","event_at":"2026-02-03T14:35:00Z","available_at":"2026-02-03T14:35:01Z","received_at":"2026-02-03T14:35:02Z","ingest_sequence":"5","book_sequence":"5","price":"100","quantity":"4","aggressor_side":"sell"},{"type":"trade","instrument_id":"clip-equity","event_at":"2026-02-03T14:36:00Z","available_at":"2026-02-03T14:36:01Z","received_at":"2026-02-03T14:36:02Z","ingest_sequence":"6","book_sequence":"6","price":"100","quantity":"6","aggressor_side":"sell"}]}}} +{"contract_version":"1","scenario_sequence":"4","record_type":"scenario_end","payload":{"slice_count":"2"}} diff --git a/contracts/v1/fixtures/quote-trade.journal.jsonl b/contracts/v1/fixtures/quote-trade.journal.jsonl new file mode 100644 index 0000000..615131a --- /dev/null +++ b/contracts/v1/fixtures/quote-trade.journal.jsonl @@ -0,0 +1,15 @@ +{"contract_version":"1","engine_sequence":"1","event_id":"quote-trade-event-000000000001","causation_ids":[],"run_id":"quote-trade","recorded_at":"2026-02-02T21:00:02.000000Z","event_type":"run_started","payload":{"scenario_sha256":"9c525db135cd830107ffd151024f807261c5e9403ec4e3734109fab1e0da8623","execution_model":"quote_trade_v1"}} +{"contract_version":"1","engine_sequence":"2","event_id":"quote-trade-event-000000000002","causation_ids":["quote-trade-event-000000000001"],"run_id":"quote-trade","recorded_at":"2026-02-02T21:00:02.000000Z","event_type":"initial_state","payload":{"portfolio":{"cash":[{"currency":"USD","amount":"2000"}],"positions":[],"marks":[],"fx_rates":[{"currency":"USD","rate":"1"}]},"valuation":{"base_currency":"USD","cash":"2000","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":"2000","dividend_pnl":"0","execution_fees":"0","execution_fee_components":[],"borrow_fees":"0","total_fees":"0","cash_interest":"0","settled_cash":"2000","unsettled_cash":"0","cash_balances":[{"currency":"USD","amount":"2000","settled_amount":"2000","unsettled_amount":"0","fx_rate":"1","base_value":"2000","base_settled_value":"2000","base_unsettled_value":"0","interest":"0","base_interest":"0"}],"positions":[],"margin":{"initial_requirement":"0","maintenance_requirement":"0","initial_excess":"2000","maintenance_excess":"2000","margin_call":false},"group_exposures":[]}}} +{"contract_version":"1","engine_sequence":"3","event_id":"quote-trade-event-000000000003","causation_ids":["quote-trade-event-000000000002"],"run_id":"quote-trade","recorded_at":"2026-02-02T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"2000","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":"2000","dividend_pnl":"0","execution_fees":"0","execution_fee_components":[],"borrow_fees":"0","total_fees":"0","cash_interest":"0","settled_cash":"2000","unsettled_cash":"0","cash_balances":[{"currency":"USD","amount":"2000","settled_amount":"2000","unsettled_amount":"0","fx_rate":"1","base_value":"2000","base_settled_value":"2000","base_unsettled_value":"0","interest":"0","base_interest":"0"}],"positions":[],"margin":{"initial_requirement":"0","maintenance_requirement":"0","initial_excess":"2000","maintenance_excess":"2000","margin_call":false},"group_exposures":[]}} +{"contract_version":"1","engine_sequence":"4","event_id":"quote-trade-event-000000000004","causation_ids":[],"run_id":"quote-trade","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":[],"market_events":[],"order_book_events":[]}} +{"contract_version":"1","engine_sequence":"5","event_id":"quote-trade-event-000000000005","causation_ids":["quote-trade-event-000000000004"],"run_id":"quote-trade","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":"2000","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.01484","closing_balance":"2000.01484"}} +{"contract_version":"1","engine_sequence":"6","event_id":"quote-trade-event-000000000006","causation_ids":["quote-trade-event-000000000004"],"run_id":"quote-trade","recorded_at":"2026-02-02T21:00:02.000000Z","event_type":"order_accepted","payload":{"order_id":"quote-trade-order-000000000001","instrument_id":"clip-equity","side":"buy","quantity":"10","order_kind":"limit","trigger_price":null,"limit_price":"100","time_in_force":"gtc","venue_id":null,"calendar_id":null,"expires_at":null,"origin":"direct","created_event_id":"quote-trade-event-000000000006","updated_event_id":"quote-trade-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":"1","engine_sequence":"7","event_id":"quote-trade-event-000000000007","causation_ids":["quote-trade-event-000000000004"],"run_id":"quote-trade","recorded_at":"2026-02-02T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"2000.01484","net_market_value":"0","long_market_value":"0","short_market_value":"0","gross_exposure":"0","cost_basis":"0","realized_pnl":"0.01484","unrealized_pnl":"0","equity":"2000.01484","dividend_pnl":"0","execution_fees":"0","execution_fee_components":[],"borrow_fees":"0","total_fees":"0","cash_interest":"0.01484","settled_cash":"2000.01484","unsettled_cash":"0","cash_balances":[{"currency":"USD","amount":"2000.01484","settled_amount":"2000.01484","unsettled_amount":"0","fx_rate":"1","base_value":"2000.01484","base_settled_value":"2000.01484","base_unsettled_value":"0","interest":"0.01484","base_interest":"0.01484"}],"positions":[{"instrument_id":"clip-equity","quote_currency":"USD","quantity":"0","settled_quantity":"0","unsettled_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","execution_fee_components":[],"borrow_fees":"0","base_borrow_fees":"0","total_fees":"0","base_total_fees":"0"}],"margin":{"initial_requirement":"0","maintenance_requirement":"0","initial_excess":"2000.01484","maintenance_excess":"2000.01484","margin_call":false},"group_exposures":[]}} +{"contract_version":"1","engine_sequence":"8","event_id":"quote-trade-event-000000000008","causation_ids":[],"run_id":"quote-trade","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":[],"market_events":[{"type":"quote","instrument_id":"clip-equity","event_at":"2026-02-03T14:31:00.000000Z","available_at":"2026-02-03T14:31:01.000000Z","received_at":"2026-02-03T14:31:02.000000Z","ingest_sequence":"1","bid_price":"99","bid_quantity":"20","ask_price":"101","ask_quantity":"20"},{"type":"trade","instrument_id":"clip-equity","event_at":"2026-02-03T14:32:00.000000Z","available_at":"2026-02-03T14:32:01.000000Z","received_at":"2026-02-03T14:32:02.000000Z","ingest_sequence":"2","price":"100","quantity":"5","aggressor_side":"unknown"},{"type":"trade","instrument_id":"clip-equity","event_at":"2026-02-03T14:33:00.000000Z","available_at":"2026-02-03T14:33:01.000000Z","received_at":"2026-02-03T14:33:02.000000Z","ingest_sequence":"3","price":"99","quantity":"4","aggressor_side":"sell"},{"type":"trade","instrument_id":"clip-equity","event_at":"2026-02-03T14:34:00.000000Z","available_at":"2026-02-03T14:34:01.000000Z","received_at":"2026-02-03T14:34:02.000000Z","ingest_sequence":"4","price":"100","quantity":"10","aggressor_side":"sell"}],"order_book_events":[]}} +{"contract_version":"1","engine_sequence":"9","event_id":"quote-trade-event-000000000009","causation_ids":["quote-trade-event-000000000008"],"run_id":"quote-trade","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":"2000.01484","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.01484","closing_balance":"2000.02968"}} +{"contract_version":"1","engine_sequence":"10","event_id":"quote-trade-event-000000000010","causation_ids":["quote-trade-event-000000000006","quote-trade-event-000000000008"],"run_id":"quote-trade","recorded_at":"2026-02-03T21:00:02.000000Z","event_type":"fill_applied","payload":{"fill_id":"quote-trade-fill-000000000001","order_id":"quote-trade-order-000000000001","instrument_id":"clip-equity","quote_currency":"USD","side":"buy","quantity":"4","price":"99","notional":"396","fee":"10","fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"10","quote_amount":"10"}],"executed_at":"2026-02-03T14:33:00.000000Z","slice_sequence":"2"}} +{"contract_version":"1","engine_sequence":"11","event_id":"quote-trade-event-000000000011","causation_ids":["quote-trade-event-000000000010"],"run_id":"quote-trade","recorded_at":"2026-02-03T21:00:02.000000Z","event_type":"settlement_instruction_created","payload":{"instruction_id":"quote-trade-fill-000000000001-settlement","fill_id":"quote-trade-fill-000000000001","instrument_id":"clip-equity","currency":"USD","cash_movement":"-406","position_movement":"4","trade_date":"2026-02-03","due_date":"2026-02-04","status":"pending","settled_at":null,"failed_at":null,"failure_reason":null}} +{"contract_version":"1","engine_sequence":"12","event_id":"quote-trade-event-000000000012","causation_ids":["quote-trade-event-000000000006","quote-trade-event-000000000008"],"run_id":"quote-trade","recorded_at":"2026-02-03T21:00:02.000000Z","event_type":"fill_applied","payload":{"fill_id":"quote-trade-fill-000000000002","order_id":"quote-trade-order-000000000001","instrument_id":"clip-equity","quote_currency":"USD","side":"buy","quantity":"6","price":"100","notional":"600","fee":"10","fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"10","quote_amount":"10"}],"executed_at":"2026-02-03T14:34:00.000000Z","slice_sequence":"2"}} +{"contract_version":"1","engine_sequence":"13","event_id":"quote-trade-event-000000000013","causation_ids":["quote-trade-event-000000000012"],"run_id":"quote-trade","recorded_at":"2026-02-03T21:00:02.000000Z","event_type":"settlement_instruction_created","payload":{"instruction_id":"quote-trade-fill-000000000002-settlement","fill_id":"quote-trade-fill-000000000002","instrument_id":"clip-equity","currency":"USD","cash_movement":"-610","position_movement":"6","trade_date":"2026-02-03","due_date":"2026-02-04","status":"pending","settled_at":null,"failed_at":null,"failure_reason":null}} +{"contract_version":"1","engine_sequence":"14","event_id":"quote-trade-event-000000000014","causation_ids":["quote-trade-event-000000000008"],"run_id":"quote-trade","recorded_at":"2026-02-03T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"984.02968","net_market_value":"1000","long_market_value":"1000","short_market_value":"0","gross_exposure":"1000","cost_basis":"1016","realized_pnl":"0.02968","unrealized_pnl":"-16","equity":"1984.02968","dividend_pnl":"0","execution_fees":"20","execution_fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"20","quote_currency":"USD","quote_amount":"20","base_amount":"20"}],"borrow_fees":"0","total_fees":"20","cash_interest":"0.02968","settled_cash":"2000.02968","unsettled_cash":"-1016","cash_balances":[{"currency":"USD","amount":"984.02968","settled_amount":"2000.02968","unsettled_amount":"-1016","fx_rate":"1","base_value":"984.02968","base_settled_value":"2000.02968","base_unsettled_value":"-1016","interest":"0.02968","base_interest":"0.02968"}],"positions":[{"instrument_id":"clip-equity","quote_currency":"USD","quantity":"10","settled_quantity":"0","unsettled_quantity":"10","mark":"100","fx_rate":"1","market_value":"1000","base_market_value":"1000","cost_basis":"1016","base_cost_basis":"1016","realized_pnl":"0","base_realized_pnl":"0","unrealized_pnl":"-16","base_unrealized_pnl":"-16","dividend_pnl":"0","base_dividend_pnl":"0","execution_fees":"20","base_execution_fees":"20","execution_fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"20","quote_currency":"USD","quote_amount":"20","base_amount":"20"}],"borrow_fees":"0","base_borrow_fees":"0","total_fees":"20","base_total_fees":"20"}],"margin":{"initial_requirement":"500","maintenance_requirement":"250","initial_excess":"1484.02968","maintenance_excess":"1734.02968","margin_call":false},"group_exposures":[]}} +{"contract_version":"1","engine_sequence":"15","event_id":"quote-trade-event-000000000015","causation_ids":["quote-trade-event-000000000014"],"run_id":"quote-trade","recorded_at":"2026-02-03T21:00:02.000000Z","event_type":"run_completed","payload":{"scenario_sha256":"9c525db135cd830107ffd151024f807261c5e9403ec4e3734109fab1e0da8623","execution_model":"quote_trade_v1","valuation":{"base_currency":"USD","cash":"984.02968","net_market_value":"1000","long_market_value":"1000","short_market_value":"0","gross_exposure":"1000","cost_basis":"1016","realized_pnl":"0.02968","unrealized_pnl":"-16","equity":"1984.02968","dividend_pnl":"0","execution_fees":"20","execution_fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"20","quote_currency":"USD","quote_amount":"20","base_amount":"20"}],"borrow_fees":"0","total_fees":"20","cash_interest":"0.02968","settled_cash":"2000.02968","unsettled_cash":"-1016","cash_balances":[{"currency":"USD","amount":"984.02968","settled_amount":"2000.02968","unsettled_amount":"-1016","fx_rate":"1","base_value":"984.02968","base_settled_value":"2000.02968","base_unsettled_value":"-1016","interest":"0.02968","base_interest":"0.02968"}],"positions":[{"instrument_id":"clip-equity","quote_currency":"USD","quantity":"10","settled_quantity":"0","unsettled_quantity":"10","mark":"100","fx_rate":"1","market_value":"1000","base_market_value":"1000","cost_basis":"1016","base_cost_basis":"1016","realized_pnl":"0","base_realized_pnl":"0","unrealized_pnl":"-16","base_unrealized_pnl":"-16","dividend_pnl":"0","base_dividend_pnl":"0","execution_fees":"20","base_execution_fees":"20","execution_fee_components":[{"name":"broker","kind":"fixed","currency":"USD","amount":"20","quote_currency":"USD","quote_amount":"20","base_amount":"20"}],"borrow_fees":"0","base_borrow_fees":"0","total_fees":"20","base_total_fees":"20"}],"margin":{"initial_requirement":"500","maintenance_requirement":"250","initial_excess":"1484.02968","maintenance_excess":"1734.02968","margin_call":false},"group_exposures":[]},"order_counts":{"total":1,"active":0,"filled":1,"rejected":0,"cancelled":0}}} diff --git a/contracts/v1/fixtures/quote-trade.scenario.json b/contracts/v1/fixtures/quote-trade.scenario.json new file mode 100644 index 0000000..42e6682 --- /dev/null +++ b/contracts/v1/fixtures/quote-trade.scenario.json @@ -0,0 +1,318 @@ +{ + "contract_version": "1", + "metadata": { + "producer": "trading-engine", + "purpose": "bounded quote and trade replay fixture" + }, + "run_id": "quote-trade", + "base_currency": "USD", + "initial_portfolio": { + "cash": [ + { + "currency": "USD", + "amount": "2000" + } + ], + "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", + "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": "quote_trade_v1", + "configuration": { + "version": "1", + "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": "limit", + "trigger_price": null, + "limit_price": "100", + "time_in_force": "gtc", + "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": [], + "market_events": [], + "order_book_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": [], + "market_events": [ + { + "type": "quote", + "instrument_id": "clip-equity", + "event_at": "2026-02-03T14:31:00Z", + "available_at": "2026-02-03T14:31:01Z", + "received_at": "2026-02-03T14:31:02Z", + "ingest_sequence": "1", + "bid_price": "99", + "bid_quantity": "20", + "ask_price": "101", + "ask_quantity": "20" + }, + { + "type": "trade", + "instrument_id": "clip-equity", + "event_at": "2026-02-03T14:32:00Z", + "available_at": "2026-02-03T14:32:01Z", + "received_at": "2026-02-03T14:32:02Z", + "ingest_sequence": "2", + "price": "100", + "quantity": "5", + "aggressor_side": "unknown" + }, + { + "type": "trade", + "instrument_id": "clip-equity", + "event_at": "2026-02-03T14:33:00Z", + "available_at": "2026-02-03T14:33:01Z", + "received_at": "2026-02-03T14:33:02Z", + "ingest_sequence": "3", + "price": "99", + "quantity": "4", + "aggressor_side": "sell" + }, + { + "type": "trade", + "instrument_id": "clip-equity", + "event_at": "2026-02-03T14:34:00Z", + "available_at": "2026-02-03T14:34:01Z", + "received_at": "2026-02-03T14:34:02Z", + "ingest_sequence": "4", + "price": "100", + "quantity": "10", + "aggressor_side": "sell" + } + ], + "order_book_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/v1/fixtures/quote-trade.scenario.jsonl b/contracts/v1/fixtures/quote-trade.scenario.jsonl new file mode 100644 index 0000000..b02c21c --- /dev/null +++ b/contracts/v1/fixtures/quote-trade.scenario.jsonl @@ -0,0 +1,4 @@ +{"contract_version":"1","scenario_sequence":"1","record_type":"scenario_header","payload":{"metadata":{"producer":"trading-engine","purpose":"bounded quote and trade replay fixture"},"run_id":"quote-trade","base_currency":"USD","initial_portfolio":{"cash":[{"currency":"USD","amount":"2000"}],"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","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":"quote_trade_v1","configuration":{"version":"1","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,"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}]}}} +{"contract_version":"1","scenario_sequence":"2","record_type":"market_slice","payload":{"market_slice":{"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":[],"market_events":[],"order_book_events":[]},"intents":[{"type":"submit_order","instrument_id":"clip-equity","side":"buy","quantity":"10","order_kind":"limit","trigger_price":null,"limit_price":"100","time_in_force":"gtc","venue_id":null,"calendar_id":null,"expires_at":null}]}} +{"contract_version":"1","scenario_sequence":"3","record_type":"market_slice","payload":{"market_slice":{"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":[],"market_events":[{"type":"quote","instrument_id":"clip-equity","event_at":"2026-02-03T14:31:00Z","available_at":"2026-02-03T14:31:01Z","received_at":"2026-02-03T14:31:02Z","ingest_sequence":"1","bid_price":"99","bid_quantity":"20","ask_price":"101","ask_quantity":"20"},{"type":"trade","instrument_id":"clip-equity","event_at":"2026-02-03T14:32:00Z","available_at":"2026-02-03T14:32:01Z","received_at":"2026-02-03T14:32:02Z","ingest_sequence":"2","price":"100","quantity":"5","aggressor_side":"unknown"},{"type":"trade","instrument_id":"clip-equity","event_at":"2026-02-03T14:33:00Z","available_at":"2026-02-03T14:33:01Z","received_at":"2026-02-03T14:33:02Z","ingest_sequence":"3","price":"99","quantity":"4","aggressor_side":"sell"},{"type":"trade","instrument_id":"clip-equity","event_at":"2026-02-03T14:34:00Z","available_at":"2026-02-03T14:34:01Z","received_at":"2026-02-03T14:34:02Z","ingest_sequence":"4","price":"100","quantity":"10","aggressor_side":"sell"}],"order_book_events":[]},"intents":[]}} +{"contract_version":"1","scenario_sequence":"4","record_type":"scenario_end","payload":{"slice_count":"2"}} diff --git a/contracts/v1/journal.schema.json b/contracts/v1/journal.schema.json index 4308c58..a181854 100644 --- a/contracts/v1/journal.schema.json +++ b/contracts/v1/journal.schema.json @@ -7,82 +7,400 @@ "required": [ "contract_version", "engine_sequence", + "event_id", + "causation_ids", "run_id", "recorded_at", "event_type", "payload" ], "properties": { - "contract_version": { "const": "1" }, - "engine_sequence": { "$ref": "#/$defs/sequence" }, - "run_id": { "$ref": "#/$defs/identifier" }, - "recorded_at": { "$ref": "#/$defs/timestamp" }, + "contract_version": { + "const": "1" + }, + "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", + "execution_price_selected", "fill_applied", - "cash_limited", + "settlement_instruction_created", + "settlement_completed", + "settlement_failed", + "fill_clipped", + "borrow_charge_applied", + "borrow_recall_received", + "cash_interest_applied", + "margin_call", + "margin_restored", "intent_rejected", "metric_emitted", "valuation", "run_completed" ] }, - "payload": { "type": "object" } + "payload": { + "type": "object" + } }, "allOf": [ { - "if": { "properties": { "event_type": { "const": "run_started" } } }, - "then": { "properties": { "payload": { "$ref": "#/$defs/runStarted" } } } + "if": { "properties": { "event_type": { "const": "execution_price_selected" } } }, + "then": { "properties": { "payload": { "$ref": "#/$defs/executionPriceSelected" } } } + }, + { + "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": "market_slice_received" } } }, - "then": { "properties": { "payload": { "$ref": "#/$defs/marketSlice" } } } + "if": { + "properties": { + "event_type": { + "const": "order_adjusted" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/orderAdjusted" + } + } + } }, { - "if": { "properties": { "event_type": { "const": "target_portfolio_requested" } } }, - "then": { "properties": { "payload": { "$ref": "#/$defs/targetPortfolio" } } } + "if": { + "properties": { + "event_type": { + "const": "fill_applied" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/fill" + } + } + } }, { - "if": { "properties": { "event_type": { "const": "order_accepted" } } }, - "then": { "properties": { "payload": { "$ref": "#/$defs/order" } } } + "if": { + "properties": { + "event_type": { + "enum": [ + "settlement_instruction_created", + "settlement_completed", + "settlement_failed" + ] + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/settlementInstruction" + } + } + } }, { - "if": { "properties": { "event_type": { "const": "order_rejected" } } }, - "then": { "properties": { "payload": { "$ref": "#/$defs/order" } } } + "if": { + "properties": { + "event_type": { + "const": "fill_clipped" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/fillClipped" + } + } + } }, { - "if": { "properties": { "event_type": { "const": "order_cancelled" } } }, - "then": { "properties": { "payload": { "$ref": "#/$defs/orderCancelled" } } } + "if": { + "properties": { + "event_type": { + "const": "borrow_charge_applied" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/borrowCharge" + } + } + } }, { - "if": { "properties": { "event_type": { "const": "fill_applied" } } }, - "then": { "properties": { "payload": { "$ref": "#/$defs/fill" } } } + "if": { + "properties": { + "event_type": { + "const": "borrow_recall_received" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/borrowRecall" + } + } + } }, { - "if": { "properties": { "event_type": { "const": "cash_limited" } } }, - "then": { "properties": { "payload": { "$ref": "#/$defs/cashLimited" } } } + "if": { + "properties": { + "event_type": { + "const": "cash_interest_applied" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/cashInterest" + } + } + } }, { - "if": { "properties": { "event_type": { "const": "intent_rejected" } } }, - "then": { "properties": { "payload": { "$ref": "#/$defs/intentRejected" } } } + "if": { + "properties": { + "event_type": { + "enum": [ + "margin_call", + "margin_restored", + "valuation" + ] + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/valuation" + } + } + } }, { - "if": { "properties": { "event_type": { "const": "metric_emitted" } } }, - "then": { "properties": { "payload": { "$ref": "#/$defs/metric" } } } + "if": { + "properties": { + "event_type": { + "const": "intent_rejected" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/intentRejected" + } + } + } }, { - "if": { "properties": { "event_type": { "const": "valuation" } } }, - "then": { "properties": { "payload": { "$ref": "#/$defs/valuation" } } } + "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" } } } + "if": { + "properties": { + "event_type": { + "const": "run_completed" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/runCompleted" + } + } + } } ], "$defs": { @@ -91,58 +409,190 @@ "minLength": 1, "pattern": "^[^\\s\\u0000-\\u001f\\u007f]+$" }, - "unsignedDecimal": { + "signedDecimal": { "type": "string", - "pattern": "^(?:0|[1-9][0-9]*)(?:[.][0-9]{0,5}[1-9])?$" + "pattern": "^(?:0|-?(?:0[.][0-9]{0,5}[1-9]|[1-9][0-9]*(?:[.][0-9]{0,5}[1-9])?))$" }, - "signedDecimal": { + "unsignedDecimal": { "type": "string", - "pattern": "^(?:0(?:[.][0-9]{0,5}[1-9])?|[1-9][0-9]*(?:[.][0-9]{0,5}[1-9])?|-(?:0[.][0-9]{0,5}[1-9]|[1-9][0-9]*(?:[.][0-9]{0,5}[1-9])?))$" + "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])?)$" }, - "weight": { + "sequence": { "type": "string", - "pattern": "^(?:0(?:[.][0-9]{0,5}[1-9])?|1)$" + "pattern": "^[1-9][0-9]*$" }, - "quantity": { + "nonnegativeSequence": { "type": "string", "pattern": "^(?:0|[1-9][0-9]*)$" }, - "positiveQuantity": { "type": "string", "pattern": "^[1-9][0-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})$" }, - "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "runStarted": { "type": "object", "additionalProperties": false, - "required": ["scenario_sha256"], - "properties": { "scenario_sha256": { "$ref": "#/$defs/sha256" } } + "required": [ + "scenario_sha256", + "execution_model" + ], + "properties": { + "scenario_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "execution_model": { + "enum": ["completed_bar_v1", "completed_bar_next_open_v1", "completed_bar_adverse_touch_v1", "quote_trade_v1", "order_book_v1"] + } + } + }, + "initialState": { + "type": "object", + "additionalProperties": false, + "required": [ + "portfolio", + "valuation" + ], + "properties": { + "portfolio": { + "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/initialPortfolio" + }, + "valuation": { + "$ref": "#/$defs/valuation" + } + } }, "bar": { "type": "object", "additionalProperties": false, - "required": ["instrument_id", "open", "high", "low", "close", "volume"], + "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" }, + "instrument_id": { + "$ref": "#/$defs/identifier" + }, + "open": { + "$ref": "#/$defs/positiveDecimal" + }, + "high": { + "$ref": "#/$defs/positiveDecimal" + }, + "low": { + "$ref": "#/$defs/positiveDecimal" + }, + "close": { + "$ref": "#/$defs/positiveDecimal" + }, "volume": { "oneOf": [ - { "$ref": "#/$defs/quantity" }, - { "type": "null" } + { + "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/v1/scenario.schema.json#/$defs/fractionalPolicy" } + } + } + ] + }, "marketSlice": { "type": "object", "additionalProperties": false, @@ -152,72 +602,166 @@ "end_at", "available_at", "received_at", - "bars" + "bars", + "fx_rates", + "corporate_actions", + "borrow_observations", + "cash_rate_observations", + "settlement_failures", + "lifecycle_events", + "market_events", + "order_book_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" }, + "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" } + "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/v1/scenario.schema.json#/$defs/borrowObservation" + } + }, + "cash_rate_observations": { + "type": "array", + "items": { + "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/cashRateObservation" + } + }, + "settlement_failures": { + "type": "array", + "items": { + "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/settlementFailure" + } + }, + "lifecycle_events": { + "type": "array", + "items": { + "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/lifecycleEvent" + } + }, + "market_events": { + "type": "array", + "items": { + "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/marketEvent" + } + }, + "order_book_events": { + "type": "array", + "items": { + "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/orderBookEvent" + } } } }, - "requestedWeightTarget": { + "settlementInstruction": { "type": "object", "additionalProperties": false, - "required": ["instrument_id", "weight", "quantity", "reference_price"], + "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" }, - "weight": { "$ref": "#/$defs/weight" }, - "quantity": { "$ref": "#/$defs/quantity" }, - "reference_price": { "$ref": "#/$defs/positiveDecimal" } + "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)?$" }] } } }, - "requestedQuantityTarget": { + "targetPortfolio": { "type": "object", "additionalProperties": false, - "required": ["instrument_id", "weight", "quantity", "reference_price"], + "required": [ + "basis", + "targets" + ], "properties": { - "instrument_id": { "$ref": "#/$defs/identifier" }, - "weight": { "type": "null" }, - "quantity": { "$ref": "#/$defs/quantity" }, - "reference_price": { "type": "null" } - } - }, - "targetPortfolio": { - "oneOf": [ - { - "type": "object", - "additionalProperties": false, - "required": ["basis", "targets"], - "properties": { - "basis": { "const": "weights" }, - "targets": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/requestedWeightTarget" } - } - } + "basis": { + "enum": [ + "weights", + "quantities" + ] }, - { - "type": "object", - "additionalProperties": false, - "required": ["basis", "targets"], - "properties": { - "basis": { "const": "quantities" }, - "targets": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/requestedQuantityTarget" } + "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", @@ -228,49 +772,321 @@ "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/positiveQuantity" }, - "order_kind": { "enum": ["market", "limit"] }, + "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": [ - { "$ref": "#/$defs/positiveDecimal" }, - { "type": "null" } + { + "type": "null" + }, + { + "$ref": "#/$defs/positiveDecimal" + } ] }, - "origin": { "enum": ["direct", "target_rebalance"] }, - "created_sequence": { "$ref": "#/$defs/sequence" }, - "created_at": { "$ref": "#/$defs/timestamp" }, - "eligible_after_slice_sequence": { "$ref": "#/$defs/sequence" }, - "filled_quantity": { "$ref": "#/$defs/quantity" }, - "filled_notional": { "$ref": "#/$defs/unsignedDecimal" }, + "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"] + "enum": [ + "working", + "partially_filled", + "filled", + "cancelled", + "rejected" + ] }, "rejection_reason": { - "oneOf": [{ "type": "string" }, { "type": "null" }] + "oneOf": [ + { + "type": "null" + }, + { + "type": "string", + "minLength": 1 + } + ] } } }, "orderCancelled": { "type": "object", "additionalProperties": false, - "required": ["order", "reason"], + "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/v1/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" + } + } + }, + "executionPriceSelected": { + "type": "object", + "additionalProperties": false, + "required": ["order_id", "instrument_id", "side", "reference_price", "spread_adjustment", "impact_adjustment", "final_price"], "properties": { - "order": { "$ref": "#/$defs/order" }, - "reason": { "enum": ["strategy_requested", "target_replaced", "market_ioc"] } + "order_id": { "$ref": "#/$defs/identifier" }, + "instrument_id": { "$ref": "#/$defs/identifier" }, + "side": { "enum": ["buy", "sell"] }, + "reference_price": { "$ref": "#/$defs/positiveDecimal" }, + "spread_adjustment": { "$ref": "#/$defs/unsignedDecimal" }, + "impact_adjustment": { "$ref": "#/$defs/unsignedDecimal" }, + "final_price": { "$ref": "#/$defs/positiveDecimal" } } }, "fill": { @@ -280,102 +1096,1286 @@ "fill_id", "order_id", "instrument_id", + "quote_currency", "side", "quantity", "price", "notional", "fee", "executed_at", - "slice_sequence" + "slice_sequence", + "fee_components" ], "properties": { - "fill_id": { "$ref": "#/$defs/identifier" }, - "order_id": { "$ref": "#/$defs/identifier" }, - "instrument_id": { "$ref": "#/$defs/identifier" }, - "side": { "enum": ["buy", "sell"] }, - "quantity": { "$ref": "#/$defs/positiveQuantity" }, - "price": { "$ref": "#/$defs/positiveDecimal" }, - "notional": { "$ref": "#/$defs/unsignedDecimal" }, - "fee": { "$ref": "#/$defs/unsignedDecimal" }, - "executed_at": { "$ref": "#/$defs/timestamp" }, - "slice_sequence": { "$ref": "#/$defs/sequence" } + "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" + } } }, - "cashLimited": { + "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": [ - "order_id", "instrument_id", - "requested_quantity", - "affordable_quantity", - "price" + "unit", + "value" ], "properties": { - "order_id": { "$ref": "#/$defs/identifier" }, - "instrument_id": { "$ref": "#/$defs/identifier" }, - "requested_quantity": { "$ref": "#/$defs/positiveQuantity" }, - "affordable_quantity": { "$ref": "#/$defs/quantity" }, - "price": { "$ref": "#/$defs/positiveDecimal" } + "instrument_id": { + "$ref": "#/$defs/identifier" + }, + "unit": { + "const": "quantity" + }, + "value": { + "$ref": "#/$defs/positiveDecimal" + } } }, - "intentRejected": { + "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": ["reason"], - "properties": { "reason": { "type": "string", "minLength": 1 } } + "required": ["currency", "unit", "value"], + "properties": { + "currency": { "$ref": "#/$defs/identifier" }, + "unit": { "const": "money" }, + "value": { "$ref": "#/$defs/unsignedDecimal" } + } }, - "metric": { + "settlementPositionThreshold": { "type": "object", "additionalProperties": false, - "required": ["name", "value"], + "required": ["instrument_id", "unit", "value"], "properties": { - "name": { "type": "string", "minLength": 1 }, - "value": { "type": "string" } + "instrument_id": { "$ref": "#/$defs/identifier" }, + "unit": { "const": "quantity" }, + "value": { "$ref": "#/$defs/unsignedDecimal" } } }, - "valuation": { + "instrumentBasisPointsThreshold": { "type": "object", "additionalProperties": false, "required": [ - "cash", - "market_value", - "cost_basis", - "realized_pnl", - "unrealized_pnl", - "equity", - "total_fees" + "instrument_id", + "unit", + "value" ], "properties": { - "cash": { "$ref": "#/$defs/unsignedDecimal" }, - "market_value": { "$ref": "#/$defs/unsignedDecimal" }, - "cost_basis": { "$ref": "#/$defs/unsignedDecimal" }, - "realized_pnl": { "$ref": "#/$defs/signedDecimal" }, - "unrealized_pnl": { "$ref": "#/$defs/signedDecimal" }, - "equity": { "$ref": "#/$defs/unsignedDecimal" }, - "total_fees": { "$ref": "#/$defs/unsignedDecimal" } + "instrument_id": { + "$ref": "#/$defs/identifier" + }, + "unit": { + "const": "basis_points" + }, + "value": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + } } }, - "orderCounts": { + "instrumentShortingThreshold": { "type": "object", "additionalProperties": false, - "required": ["total", "active", "filled", "rejected", "cancelled"], + "required": [ + "instrument_id", + "value" + ], "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 } + "instrument_id": { + "$ref": "#/$defs/identifier" + }, + "value": { + "const": false + } } }, - "runCompleted": { + "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": ["scenario_sha256", "valuation", "order_counts"], + "required": [ + "group_id", + "unit", + "value" + ], "properties": { - "scenario_sha256": { "$ref": "#/$defs/sha256" }, - "valuation": { "$ref": "#/$defs/valuation" }, - "order_counts": { "$ref": "#/$defs/orderCounts" } + "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" + } + } + }, + "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/v1/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/v1/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/v1/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", "minLength": 1, "maxLength": 128, "pattern": "^\\S(?:.*\\S)?$" }, + "value": { "$ref": "#/$defs/metricValue" }, + "unit": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^\\S(?:.*\\S)?$" }, + "dimensions": { + "type": "object", + "maxProperties": 16, + "propertyNames": { "minLength": 1, "maxLength": 64, "pattern": "^\\S(?:.*\\S)?$" }, + "additionalProperties": { "type": "string", "maxLength": 128 } + }, + "aggregation": { "enum": ["last", "sum", "minimum", "maximum", "mean"] } + }, + "allOf": [ + { "if": { "properties": { "value": { "properties": { "type": { "enum": ["string", "boolean"] } } } } }, "then": { "properties": { "aggregation": { "enum": ["last"] } } } } + ] + }, + "metricValue": { + "oneOf": [ + { "type": "object", "additionalProperties": false, "required": ["type", "value"], "properties": { "type": { "const": "numeric" }, "value": { "type": "string", "pattern": "^(0|[1-9][0-9]*|-[1-9][0-9]*)(\\.[0-9]*[1-9])?$|^-0\\.[0-9]*[1-9]$" } } }, + { "type": "object", "additionalProperties": false, "required": ["type", "value"], "properties": { "type": { "const": "string" }, "value": { "type": "string", "maxLength": 1024 } } }, + { "type": "object", "additionalProperties": false, "required": ["type", "value"], "properties": { "type": { "const": "boolean" }, "value": { "type": "boolean" } } } + ] + }, + "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": { + "enum": ["completed_bar_v1", "completed_bar_next_open_v1", "completed_bar_adverse_touch_v1", "quote_trade_v1", "order_book_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/v1/scenario-stream.schema.json b/contracts/v1/scenario-stream.schema.json index c1c3d91..6743c1a 100644 --- a/contracts/v1/scenario-stream.schema.json +++ b/contracts/v1/scenario-stream.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/fallblu/trading-engine/contracts/v1/scenario-stream.schema.json", "title": "Trading Engine v1 replay scenario stream record", - "description": "The structural contract for one record in a bounded-memory JSON Lines replay scenario. The engine additionally checks record order, terminal counts, catalog coverage, market ordering, causality, risk, tick, lot, time, and OHLC invariants.", + "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" }, @@ -12,12 +12,7 @@ "headerRecord": { "type": "object", "additionalProperties": false, - "required": [ - "contract_version", - "scenario_sequence", - "record_type", - "payload" - ], + "required": ["contract_version", "scenario_sequence", "record_type", "payload"], "properties": { "contract_version": { "const": "1" }, "scenario_sequence": { "const": "1" }, @@ -28,17 +23,10 @@ "sliceRecord": { "type": "object", "additionalProperties": false, - "required": [ - "contract_version", - "scenario_sequence", - "record_type", - "payload" - ], + "required": ["contract_version", "scenario_sequence", "record_type", "payload"], "properties": { "contract_version": { "const": "1" }, - "scenario_sequence": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/sequence" - }, + "scenario_sequence": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/sequence" }, "record_type": { "const": "market_slice" }, "payload": { "$ref": "#/$defs/slicePayload" } } @@ -46,59 +34,35 @@ "endRecord": { "type": "object", "additionalProperties": false, - "required": [ - "contract_version", - "scenario_sequence", - "record_type", - "payload" - ], + "required": ["contract_version", "scenario_sequence", "record_type", "payload"], "properties": { "contract_version": { "const": "1" }, - "scenario_sequence": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/sequence" - }, + "scenario_sequence": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/sequence" }, "record_type": { "const": "scenario_end" }, - "payload": { "$ref": "#/$defs/endPayload" } + "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_cash", - "instruments", - "risk", - "execution", - "max_internal_events" - ], + "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/v1/scenario.schema.json#/$defs/identifier" - }, - "base_currency": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/identifier" - }, - "initial_cash": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/unsignedDecimal" - }, - "instruments": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/instrument" - } - }, - "risk": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/risk" - }, - "execution": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/execution" - }, - "max_internal_events": { "type": "integer", "minimum": 1 } + "run_id": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/identifier" }, + "base_currency": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/identifier" }, + "initial_portfolio": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/initialPortfolio" }, + "instruments": { "type": "array", "minItems": 1, "maxItems": 4096, "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/instrument" } }, + "venue_calendars": { "type": "array", "minItems": 1, "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/venueCalendar" } }, + "risk": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/risk" }, + "execution": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/execution" }, + "financing": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/financing" }, + "settlement": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/settlement" }, + "max_internal_events": { "type": "integer", "minimum": 1, "maximum": 100000 } } }, "slicePayload": { @@ -106,25 +70,8 @@ "additionalProperties": false, "required": ["market_slice", "intents"], "properties": { - "market_slice": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/marketSlice" - }, - "intents": { - "type": "array", - "items": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/intent" - } - } - } - }, - "endPayload": { - "type": "object", - "additionalProperties": false, - "required": ["slice_count"], - "properties": { - "slice_count": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/quantity" - } + "market_slice": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/marketSlice" }, + "intents": { "type": "array", "maxItems": 4096, "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json#/$defs/intent" } } } } } diff --git a/contracts/v1/scenario.schema.json b/contracts/v1/scenario.schema.json index 2d1ffaf..48d09a1 100644 --- a/contracts/v1/scenario.schema.json +++ b/contracts/v1/scenario.schema.json @@ -2,255 +2,549 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/fallblu/trading-engine/contracts/v1/scenario.schema.json", "title": "Trading Engine v1 replay scenario", - "description": "The strict structural input contract. The engine additionally checks catalog coverage, ordering, causality, risk, tick, lot, time, and OHLC invariants.", + "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_cash", - "instruments", - "risk", - "execution", - "max_internal_events", - "schedule", - "slices" - ], + "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": "1" }, "metadata": { "type": "object" }, "run_id": { "$ref": "#/$defs/identifier" }, "base_currency": { "$ref": "#/$defs/identifier" }, - "initial_cash": { "$ref": "#/$defs/unsignedDecimal" }, + "initial_portfolio": { "$ref": "#/$defs/initialPortfolio" }, "instruments": { "type": "array", "minItems": 1, + "maxItems": 4096, "items": { "$ref": "#/$defs/instrument" } }, - "risk": { "$ref": "#/$defs/risk" }, - "execution": { "$ref": "#/$defs/execution" }, - "max_internal_events": { "type": "integer", "minimum": 1 }, - "schedule": { + "venue_calendars": { "type": "array", - "items": { "$ref": "#/$defs/scheduleItem" } + "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|[1-9][0-9]*)(?:[.][0-9]{0,5}[1-9])?$" + "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])?)$" }, - "weight": { - "type": "string", - "pattern": "^(?:0(?:[.][0-9]{0,5}[1-9])?|1)$" - }, - "quantity": { - "type": "string", - "pattern": "^(?:0|[1-9][0-9]*)$" - }, - "positiveQuantity": { - "type": "string", - "pattern": "^[1-9][0-9]*$" - }, - "sequence": { - "type": "string", - "pattern": "^[1-9][0-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})$" }, - "instrument": { + "cashBalance": { + "type": "object", + "additionalProperties": false, + "required": ["currency", "amount"], + "properties": { + "currency": { "$ref": "#/$defs/identifier" }, + "amount": { "$ref": "#/$defs/signedDecimal" } + } + }, + "initialPortfolio": { "type": "object", "additionalProperties": false, - "required": [ - "instrument_id", - "symbol", - "quote_currency", - "tick_size", - "lot_size" - ], + "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" }, - "symbol": { "$ref": "#/$defs/identifier" }, - "quote_currency": { "$ref": "#/$defs/identifier" }, - "tick_size": { "$ref": "#/$defs/positiveDecimal" }, - "lot_size": { "$ref": "#/$defs/positiveQuantity" } + "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" } } }, - "risk": { + "initialMark": { "type": "object", "additionalProperties": false, - "required": ["max_order_quantity", "max_position"], + "required": ["instrument_id", "price"], "properties": { - "max_order_quantity": { "$ref": "#/$defs/positiveQuantity" }, - "max_position": { "$ref": "#/$defs/positiveQuantity" } + "instrument_id": { "$ref": "#/$defs/identifier" }, + "price": { "$ref": "#/$defs/positiveDecimal" } } }, - "execution": { + "instrument": { "type": "object", "additionalProperties": false, - "required": ["participation_bps", "fixed_fee", "fee_bps"], + "required": ["instrument_id", "symbol", "quote_currency", "tick_size", "lot_size"], "properties": { - "participation_bps": { - "type": "integer", - "minimum": 0, - "maximum": 10000 - }, - "fixed_fee": { "$ref": "#/$defs/unsignedDecimal" }, - "fee_bps": { "type": "integer", "minimum": 0, "maximum": 10000 } + "instrument_id": { "$ref": "#/$defs/identifier" }, + "symbol": { "$ref": "#/$defs/identifier" }, + "quote_currency": { "$ref": "#/$defs/identifier" }, + "tick_size": { "$ref": "#/$defs/positiveDecimal" }, + "lot_size": { "$ref": "#/$defs/positiveDecimal" } } }, - "scheduleItem": { + "venueCalendar": { "type": "object", "additionalProperties": false, - "required": ["after_slice_sequence", "intents"], + "required": ["calendar_id", "calendar_version", "venue_id", "instrument_ids", "sessions"], "properties": { - "after_slice_sequence": { "$ref": "#/$defs/sequence" }, - "intents": { + "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", - "items": { "$ref": "#/$defs/intent" } + "minItems": 1, + "items": { "$ref": "#/$defs/venueSession" } } } }, - "intent": { + "venueSession": { "oneOf": [ - { "$ref": "#/$defs/targetWeightsIntent" }, - { "$ref": "#/$defs/targetQuantitiesIntent" }, - { "$ref": "#/$defs/marketOrderIntent" }, - { "$ref": "#/$defs/limitOrderIntent" }, - { "$ref": "#/$defs/cancelOrderIntent" }, - { "$ref": "#/$defs/metricIntent" } + { "$ref": "#/$defs/openVenueSession" }, + { "$ref": "#/$defs/holidayVenueSession" } ] }, - "targetWeightsIntent": { + "openVenueSession": { "type": "object", "additionalProperties": false, - "required": ["type", "targets"], + "required": ["session_date", "policy", "phases"], "properties": { - "type": { "const": "target_weights" }, - "targets": { + "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, - "items": { "$ref": "#/$defs/weightTarget" } + "maxItems": 5, + "items": { "$ref": "#/$defs/venuePhase" } } } }, - "weightTarget": { + "holidayVenueSession": { "type": "object", "additionalProperties": false, - "required": ["instrument_id", "weight"], + "required": ["session_date", "policy", "phases"], "properties": { - "instrument_id": { "$ref": "#/$defs/identifier" }, - "weight": { "$ref": "#/$defs/weight" } + "session_date": { "type": "string", "format": "date", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }, + "policy": { "const": "holiday" }, + "phases": { "type": "array", "maxItems": 0 } } }, - "targetQuantitiesIntent": { + "venuePhase": { "type": "object", "additionalProperties": false, - "required": ["type", "targets"], + "required": ["phase", "opens_at", "closes_at"], "properties": { - "type": { "const": "target_quantities" }, - "targets": { + "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", "instrument_policies", "groups"], + "properties": { + "max_gross_exposure": { "$ref": "#/$defs/positiveDecimal" }, + "max_leverage": { "$ref": "#/$defs/positiveDecimal" }, + "instrument_policies": { "type": "array", "minItems": 1, - "items": { "$ref": "#/$defs/quantityTarget" } + "items": { "$ref": "#/$defs/instrumentRiskPolicy" } + }, + "groups": { + "type": "array", + "items": { "$ref": "#/$defs/riskGroup" } } } }, - "quantityTarget": { + "instrumentRiskPolicy": { "type": "object", "additionalProperties": false, - "required": ["instrument_id", "quantity"], + "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" }, - "quantity": { "$ref": "#/$defs/quantity" } + "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" } } }, - "orderFields": { + "nullablePositiveDecimal": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/positiveDecimal" } + ] + }, + "riskGroup": { "type": "object", - "required": [ - "type", - "instrument_id", - "side", - "quantity", - "order_kind", - "limit_price" - ], + "additionalProperties": false, + "required": ["group_id", "group_version", "group_type", "instrument_ids", "limits"], "properties": { - "type": { "const": "submit_order" }, - "instrument_id": { "$ref": "#/$defs/identifier" }, - "side": { "enum": ["buy", "sell"] }, - "quantity": { "$ref": "#/$defs/positiveQuantity" } + "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" } } }, - "marketOrderIntent": { - "allOf": [ - { "$ref": "#/$defs/orderFields" }, + "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": { + "oneOf": [ { "type": "object", "additionalProperties": false, - "required": [ - "type", - "instrument_id", - "side", - "quantity", - "order_kind", - "limit_price" - ], + "required": ["model", "configuration"], "properties": { - "type": true, - "instrument_id": true, - "side": true, - "quantity": true, - "order_kind": { "const": "market" }, - "limit_price": { "type": "null" } + "model": { "const": "completed_bar_v1" }, + "configuration": { "$ref": "#/$defs/completedBarV1Configuration" } } - } - ] - }, - "limitOrderIntent": { - "allOf": [ - { "$ref": "#/$defs/orderFields" }, + }, { "type": "object", "additionalProperties": false, - "required": [ - "type", - "instrument_id", - "side", - "quantity", - "order_kind", - "limit_price" - ], + "required": ["model", "configuration"], "properties": { - "type": true, - "instrument_id": true, - "side": true, - "quantity": true, - "order_kind": { "const": "limit" }, - "limit_price": { "$ref": "#/$defs/positiveDecimal" } + "model": { "enum": ["completed_bar_next_open_v1", "completed_bar_adverse_touch_v1"] }, + "configuration": { "$ref": "#/$defs/conservativeBarConfiguration" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["model", "configuration"], + "properties": { + "model": { "const": "quote_trade_v1" }, + "configuration": { "$ref": "#/$defs/quoteTradeConfiguration" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["model", "configuration"], + "properties": { + "model": { "const": "order_book_v1" }, + "configuration": { "$ref": "#/$defs/orderBookConfiguration" } } } ] }, - "cancelOrderIntent": { + "completedBarV1Configuration": { + "type": "object", + "additionalProperties": false, + "required": ["version", "participation_bps", "fee_schedules"], + "properties": { + "version": { "const": "1" }, + "participation_bps": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "fee_schedules": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/feeSchedule" } } + } + }, + "conservativeBarConfiguration": { + "type": "object", + "additionalProperties": false, + "required": ["version", "participation_bps", "fee_schedules", "spread_model", "impact_model"], + "properties": { + "version": { "const": "1" }, + "participation_bps": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "fee_schedules": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/feeSchedule" } }, + "spread_model": { "$ref": "#/$defs/fixedSpreadModel" }, + "impact_model": { "$ref": "#/$defs/linearImpactModel" } + } + }, + "quoteTradeConfiguration": { + "type": "object", + "additionalProperties": false, + "required": ["version", "participation_bps", "fee_schedules"], + "properties": { + "version": { "const": "1" }, + "participation_bps": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "fee_schedules": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/feeSchedule" } } + } + }, + "orderBookConfiguration": { + "type": "object", + "additionalProperties": false, + "required": ["version", "participation_bps", "fee_schedules", "max_depth_levels"], + "properties": { + "version": { "const": "1" }, + "participation_bps": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "fee_schedules": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/feeSchedule" } }, + "max_depth_levels": { "type": "integer", "minimum": 1, "maximum": 1024 } + } + }, + "fixedSpreadModel": { + "type": "object", + "additionalProperties": false, + "required": ["model", "half_spread_bps"], + "properties": { + "model": { "const": "fixed_half_spread_v1" }, + "half_spread_bps": { "type": "integer", "minimum": 0, "maximum": 10000 } + } + }, + "linearImpactModel": { + "type": "object", + "additionalProperties": false, + "required": ["model", "coefficient_bps", "missing_volume_policy"], + "properties": { + "model": { "const": "linear_participation_v1" }, + "coefficient_bps": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "missing_volume_policy": { "enum": ["reject", "zero_impact"] } + } + }, + "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"], @@ -259,38 +553,53 @@ "order_id": { "$ref": "#/$defs/identifier" } } }, - "metricIntent": { + "metric": { "type": "object", "additionalProperties": false, "required": ["type", "name", "value"], "properties": { "type": { "const": "emit_metric" }, - "name": { "type": "string" }, - "value": { "type": "string" } - } + "name": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^\\S(?:.*\\S)?$" }, + "value": { "$ref": "#/$defs/metricValue" }, + "unit": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^\\S(?:.*\\S)?$" }, + "dimensions": { + "type": "object", + "maxProperties": 16, + "propertyNames": { "minLength": 1, "maxLength": 64, "pattern": "^\\S(?:.*\\S)?$" }, + "additionalProperties": { "type": "string", "maxLength": 128 } + }, + "aggregation": { "enum": ["last", "sum", "minimum", "maximum", "mean"] } + }, + "allOf": [ + { "if": { "properties": { "value": { "properties": { "type": { "enum": ["string", "boolean"] } } } } }, "then": { "properties": { "aggregation": { "enum": ["last"] } } } } + ] + }, + "metricValue": { + "oneOf": [ + { "type": "object", "additionalProperties": false, "required": ["type", "value"], "properties": { "type": { "const": "numeric" }, "value": { "type": "string", "pattern": "^(0|[1-9][0-9]*|-[1-9][0-9]*)(\\.[0-9]*[1-9])?$|^-0\\.[0-9]*[1-9]$" } } }, + { "type": "object", "additionalProperties": false, "required": ["type", "value"], "properties": { "type": { "const": "string" }, "value": { "type": "string", "maxLength": 1024 } } }, + { "type": "object", "additionalProperties": false, "required": ["type", "value"], "properties": { "type": { "const": "boolean" }, "value": { "type": "boolean" } } } + ] }, "marketSlice": { "type": "object", "additionalProperties": false, - "required": [ - "slice_sequence", - "start_at", - "end_at", - "available_at", - "received_at", - "bars" - ], + "required": ["slice_sequence", "start_at", "end_at", "available_at", "received_at", "bars", "market_events", "order_book_events", "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" } - } + "bars": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/bar" } }, + "market_events": { "type": "array", "items": { "$ref": "#/$defs/marketEvent" } }, + "order_book_events": { "type": "array", "items": { "$ref": "#/$defs/orderBookEvent" } }, + "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": { @@ -303,13 +612,276 @@ "high": { "$ref": "#/$defs/positiveDecimal" }, "low": { "$ref": "#/$defs/positiveDecimal" }, "close": { "$ref": "#/$defs/positiveDecimal" }, - "volume": { - "oneOf": [ - { "$ref": "#/$defs/quantity" }, - { "type": "null" } - ] + "volume": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/unsignedDecimal" }] } + } + }, + "marketEvent": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["type", "instrument_id", "event_at", "available_at", "received_at", "ingest_sequence", "bid_price", "bid_quantity", "ask_price", "ask_quantity"], + "properties": { + "type": { "const": "quote" }, + "instrument_id": { "$ref": "#/$defs/identifier" }, + "event_at": { "$ref": "#/$defs/timestamp" }, + "available_at": { "$ref": "#/$defs/timestamp" }, + "received_at": { "$ref": "#/$defs/timestamp" }, + "ingest_sequence": { "$ref": "#/$defs/sequence" }, + "bid_price": { "$ref": "#/$defs/positiveDecimal" }, + "bid_quantity": { "$ref": "#/$defs/positiveDecimal" }, + "ask_price": { "$ref": "#/$defs/positiveDecimal" }, + "ask_quantity": { "$ref": "#/$defs/positiveDecimal" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "instrument_id", "event_at", "available_at", "received_at", "ingest_sequence", "price", "quantity", "aggressor_side"], + "properties": { + "type": { "const": "trade" }, + "instrument_id": { "$ref": "#/$defs/identifier" }, + "event_at": { "$ref": "#/$defs/timestamp" }, + "available_at": { "$ref": "#/$defs/timestamp" }, + "received_at": { "$ref": "#/$defs/timestamp" }, + "ingest_sequence": { "$ref": "#/$defs/sequence" }, + "price": { "$ref": "#/$defs/positiveDecimal" }, + "quantity": { "$ref": "#/$defs/positiveDecimal" }, + "aggressor_side": { "enum": ["buy", "sell", "unknown"] } + } } + ] + }, + "orderBookLevel": { + "type": "object", + "additionalProperties": false, + "required": ["price", "quantity"], + "properties": { + "price": { "$ref": "#/$defs/positiveDecimal" }, + "quantity": { "$ref": "#/$defs/positiveDecimal" } } + }, + "orderBookEvent": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["type", "instrument_id", "event_at", "available_at", "received_at", "ingest_sequence", "book_sequence", "bids", "asks"], + "properties": { + "type": { "const": "snapshot" }, + "instrument_id": { "$ref": "#/$defs/identifier" }, + "event_at": { "$ref": "#/$defs/timestamp" }, + "available_at": { "$ref": "#/$defs/timestamp" }, + "received_at": { "$ref": "#/$defs/timestamp" }, + "ingest_sequence": { "$ref": "#/$defs/sequence" }, + "book_sequence": { "$ref": "#/$defs/sequence" }, + "bids": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/orderBookLevel" } }, + "asks": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/orderBookLevel" } } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "instrument_id", "event_at", "available_at", "received_at", "ingest_sequence", "book_sequence", "side", "price", "quantity"], + "properties": { + "type": { "const": "set" }, + "instrument_id": { "$ref": "#/$defs/identifier" }, + "event_at": { "$ref": "#/$defs/timestamp" }, + "available_at": { "$ref": "#/$defs/timestamp" }, + "received_at": { "$ref": "#/$defs/timestamp" }, + "ingest_sequence": { "$ref": "#/$defs/sequence" }, + "book_sequence": { "$ref": "#/$defs/sequence" }, + "side": { "enum": ["bid", "ask"] }, + "price": { "$ref": "#/$defs/positiveDecimal" }, + "quantity": { "$ref": "#/$defs/positiveDecimal" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "instrument_id", "event_at", "available_at", "received_at", "ingest_sequence", "book_sequence", "side", "price"], + "properties": { + "type": { "const": "delete" }, + "instrument_id": { "$ref": "#/$defs/identifier" }, + "event_at": { "$ref": "#/$defs/timestamp" }, + "available_at": { "$ref": "#/$defs/timestamp" }, + "received_at": { "$ref": "#/$defs/timestamp" }, + "ingest_sequence": { "$ref": "#/$defs/sequence" }, + "book_sequence": { "$ref": "#/$defs/sequence" }, + "side": { "enum": ["bid", "ask"] }, + "price": { "$ref": "#/$defs/positiveDecimal" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "instrument_id", "event_at", "available_at", "received_at", "ingest_sequence", "book_sequence", "price", "quantity", "aggressor_side"], + "properties": { + "type": { "const": "trade" }, + "instrument_id": { "$ref": "#/$defs/identifier" }, + "event_at": { "$ref": "#/$defs/timestamp" }, + "available_at": { "$ref": "#/$defs/timestamp" }, + "received_at": { "$ref": "#/$defs/timestamp" }, + "ingest_sequence": { "$ref": "#/$defs/sequence" }, + "book_sequence": { "$ref": "#/$defs/sequence" }, + "price": { "$ref": "#/$defs/positiveDecimal" }, + "quantity": { "$ref": "#/$defs/positiveDecimal" }, + "aggressor_side": { "enum": ["buy", "sell", "unknown"] } + } + } + ] + }, + "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/contracts/v2/README.md b/contracts/v2/README.md deleted file mode 100644 index cb8b424..0000000 --- a/contracts/v2/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Trading Engine contract v2 - -This frozen directory preserves the historical v2 process and file contract. The current runtime -emits and advertises v3 only; these artifacts remain available for provenance and compatibility -testing by older consumers. - -- `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. -- `fixtures/demo.scenario.json`, `fixtures/demo.scenario.jsonl`, and - `fixtures/demo.journal.jsonl` form the canonical valid conformance corpus. - -Every batch scenario, scenario-stream record, and journal record carries -`"contract_version": "2"`. Consumers must reject missing or unsupported versions before -interpreting the rest of a document. - -Version 2 adds an explicit scenario execution-model selection. Every audit record also carries a -deterministic `event_id` and an ordered list of prior `causation_ids`. Valuations contain -per-instrument position attribution that reconciles exactly to their aggregate market value, cost -basis, realized and unrealized P&L, and fees. diff --git a/contracts/v2/dune b/contracts/v2/dune deleted file mode 100644 index 3ec9298..0000000 --- a/contracts/v2/dune +++ /dev/null @@ -1,10 +0,0 @@ -(install - (section share) - (package trading_engine) - (files - (journal.schema.json as contracts/v2/journal.schema.json) - (scenario-stream.schema.json as contracts/v2/scenario-stream.schema.json) - (scenario.schema.json as contracts/v2/scenario.schema.json) - (fixtures/demo.journal.jsonl as contracts/v2/fixtures/demo.journal.jsonl) - (fixtures/demo.scenario.json as contracts/v2/fixtures/demo.scenario.json) - (fixtures/demo.scenario.jsonl as contracts/v2/fixtures/demo.scenario.jsonl))) diff --git a/contracts/v2/fixtures/demo.journal.jsonl b/contracts/v2/fixtures/demo.journal.jsonl deleted file mode 100644 index a6232a7..0000000 --- a/contracts/v2/fixtures/demo.journal.jsonl +++ /dev/null @@ -1,20 +0,0 @@ -{"contract_version":"2","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":"21834e964dd6daab292e6285924384970b3341f7166ead1d38f8edb284541e44","execution_model":"completed_bar_v1"}} -{"contract_version":"2","engine_sequence":"2","event_id":"demo-event-000000000002","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"}]}} -{"contract_version":"2","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":"target_portfolio_requested","payload":{"basis":"weights","targets":[{"instrument_id":"demo-equity-acme","weight":"0.1","quantity":"9","reference_price":"104"}]}} -{"contract_version":"2","engine_sequence":"4","event_id":"demo-event-000000000004","causation_ids":["demo-event-000000000002"],"run_id":"demo","recorded_at":"2026-01-02T21:00:02.000000Z","event_type":"metric_emitted","payload":{"name":"desired_weight","value":"0.1"}} -{"contract_version":"2","engine_sequence":"5","event_id":"demo-event-000000000005","causation_ids":["demo-event-000000000002","demo-event-000000000003"],"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":"9","order_kind":"market","limit_price":null,"origin":"target_rebalance","created_event_id":"demo-event-000000000005","created_sequence":"5","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","filled_quantity":"0","filled_notional":"0","status":"working","rejection_reason":null}} -{"contract_version":"2","engine_sequence":"6","event_id":"demo-event-000000000006","causation_ids":["demo-event-000000000002"],"run_id":"demo","recorded_at":"2026-01-02T21:00:02.000000Z","event_type":"valuation","payload":{"cash":"10000","market_value":"0","cost_basis":"0","realized_pnl":"0","unrealized_pnl":"0","equity":"10000","total_fees":"0","positions":[{"instrument_id":"demo-equity-acme","quantity":"0","mark":"104","market_value":"0","cost_basis":"0","realized_pnl":"0","unrealized_pnl":"0","total_fees":"0"}]}} -{"contract_version":"2","engine_sequence":"7","event_id":"demo-event-000000000007","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"}]}} -{"contract_version":"2","engine_sequence":"8","event_id":"demo-event-000000000008","causation_ids":["demo-event-000000000005","demo-event-000000000007"],"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","side":"buy","quantity":"6","price":"103","notional":"618","fee":"0.868","executed_at":"2026-01-05T14:30:00.000000Z","slice_sequence":"2"}} -{"contract_version":"2","engine_sequence":"9","event_id":"demo-event-000000000009","causation_ids":["demo-event-000000000005","demo-event-000000000007"],"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":"9","order_kind":"market","limit_price":null,"origin":"target_rebalance","created_event_id":"demo-event-000000000005","created_sequence":"5","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","filled_quantity":"6","filled_notional":"618","status":"cancelled","rejection_reason":null},"reason":"market_ioc"}} -{"contract_version":"2","engine_sequence":"10","event_id":"demo-event-000000000010","causation_ids":["demo-event-000000000003","demo-event-000000000007"],"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":"3","order_kind":"market","limit_price":null,"origin":"target_rebalance","created_event_id":"demo-event-000000000010","created_sequence":"10","created_at":"2026-01-05T21:00:02.000000Z","eligible_after_slice_sequence":"2","filled_quantity":"0","filled_notional":"0","status":"working","rejection_reason":null}} -{"contract_version":"2","engine_sequence":"11","event_id":"demo-event-000000000011","causation_ids":["demo-event-000000000007"],"run_id":"demo","recorded_at":"2026-01-05T21:00:02.000000Z","event_type":"valuation","payload":{"cash":"9381.132","market_value":"642","cost_basis":"618.868","realized_pnl":"0","unrealized_pnl":"23.132","equity":"10023.132","total_fees":"0.868","positions":[{"instrument_id":"demo-equity-acme","quantity":"6","mark":"107","market_value":"642","cost_basis":"618.868","realized_pnl":"0","unrealized_pnl":"23.132","total_fees":"0.868"}]}} -{"contract_version":"2","engine_sequence":"12","event_id":"demo-event-000000000012","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"}]}} -{"contract_version":"2","engine_sequence":"13","event_id":"demo-event-000000000013","causation_ids":["demo-event-000000000010","demo-event-000000000012"],"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","side":"buy","quantity":"3","price":"107","notional":"321","fee":"0.571","executed_at":"2026-01-06T14:30:00.000000Z","slice_sequence":"3"}} -{"contract_version":"2","engine_sequence":"14","event_id":"demo-event-000000000014","causation_ids":["demo-event-000000000012"],"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","reference_price":null}]}} -{"contract_version":"2","engine_sequence":"15","event_id":"demo-event-000000000015","causation_ids":["demo-event-000000000012","demo-event-000000000014"],"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","order_kind":"market","limit_price":null,"origin":"target_rebalance","created_event_id":"demo-event-000000000015","created_sequence":"15","created_at":"2026-01-06T21:00:02.000000Z","eligible_after_slice_sequence":"3","filled_quantity":"0","filled_notional":"0","status":"working","rejection_reason":null}} -{"contract_version":"2","engine_sequence":"16","event_id":"demo-event-000000000016","causation_ids":["demo-event-000000000012"],"run_id":"demo","recorded_at":"2026-01-06T21:00:02.000000Z","event_type":"valuation","payload":{"cash":"9059.561","market_value":"945","cost_basis":"940.439","realized_pnl":"0","unrealized_pnl":"4.561","equity":"10004.561","total_fees":"1.439","positions":[{"instrument_id":"demo-equity-acme","quantity":"9","mark":"105","market_value":"945","cost_basis":"940.439","realized_pnl":"0","unrealized_pnl":"4.561","total_fees":"1.439"}]}} -{"contract_version":"2","engine_sequence":"17","event_id":"demo-event-000000000017","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"}]}} -{"contract_version":"2","engine_sequence":"18","event_id":"demo-event-000000000018","causation_ids":["demo-event-000000000015","demo-event-000000000017"],"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","side":"sell","quantity":"7","price":"105","notional":"735","fee":"0.985","executed_at":"2026-01-07T14:30:00.000000Z","slice_sequence":"4"}} -{"contract_version":"2","engine_sequence":"19","event_id":"demo-event-000000000019","causation_ids":["demo-event-000000000017"],"run_id":"demo","recorded_at":"2026-01-07T21:00:02.000000Z","event_type":"valuation","payload":{"cash":"9793.576","market_value":"212","cost_basis":"208.986445","realized_pnl":"2.562445","unrealized_pnl":"3.013555","equity":"10005.576","total_fees":"2.424","positions":[{"instrument_id":"demo-equity-acme","quantity":"2","mark":"106","market_value":"212","cost_basis":"208.986445","realized_pnl":"2.562445","unrealized_pnl":"3.013555","total_fees":"2.424"}]}} -{"contract_version":"2","engine_sequence":"20","event_id":"demo-event-000000000020","causation_ids":["demo-event-000000000019"],"run_id":"demo","recorded_at":"2026-01-07T21:00:02.000000Z","event_type":"run_completed","payload":{"scenario_sha256":"21834e964dd6daab292e6285924384970b3341f7166ead1d38f8edb284541e44","execution_model":"completed_bar_v1","valuation":{"cash":"9793.576","market_value":"212","cost_basis":"208.986445","realized_pnl":"2.562445","unrealized_pnl":"3.013555","equity":"10005.576","total_fees":"2.424","positions":[{"instrument_id":"demo-equity-acme","quantity":"2","mark":"106","market_value":"212","cost_basis":"208.986445","realized_pnl":"2.562445","unrealized_pnl":"3.013555","total_fees":"2.424"}]},"order_counts":{"total":3,"active":0,"filled":2,"rejected":0,"cancelled":1}}} diff --git a/contracts/v2/fixtures/demo.scenario.json b/contracts/v2/fixtures/demo.scenario.json deleted file mode 100644 index 898011a..0000000 --- a/contracts/v2/fixtures/demo.scenario.json +++ /dev/null @@ -1,135 +0,0 @@ -{ - "contract_version": "2", - "metadata": { - "producer": "trading-engine-demo", - "purpose": "deterministic conformance fixture" - }, - "run_id": "demo", - "base_currency": "USD", - "initial_cash": "10000", - "instruments": [ - { - "instrument_id": "demo-equity-acme", - "symbol": "ACME", - "quote_currency": "USD", - "tick_size": "0.01", - "lot_size": "1" - } - ], - "risk": { - "max_order_quantity": "1000", - "max_position": "1000" - }, - "execution": { - "model": "completed_bar_v1", - "participation_bps": 5000, - "fixed_fee": "0.25", - "fee_bps": 10 - }, - "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" - } - ] - } - ] - } - ], - "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" - } - ] - }, - { - "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" - } - ] - }, - { - "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" - } - ] - }, - { - "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" - } - ] - } - ] -} diff --git a/contracts/v2/fixtures/demo.scenario.jsonl b/contracts/v2/fixtures/demo.scenario.jsonl deleted file mode 100644 index 399b062..0000000 --- a/contracts/v2/fixtures/demo.scenario.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"contract_version":"2","payload":{"base_currency":"USD","execution":{"fee_bps":10,"fixed_fee":"0.25","model":"completed_bar_v1","participation_bps":5000},"initial_cash":"10000","instruments":[{"instrument_id":"demo-equity-acme","lot_size":"1","quote_currency":"USD","symbol":"ACME","tick_size":"0.01"}],"max_internal_events":1000,"metadata":{"producer":"trading-engine-demo","purpose":"deterministic conformance fixture"},"risk":{"max_order_quantity":"1000","max_position":"1000"},"run_id":"demo"},"record_type":"scenario_header","scenario_sequence":"1"} -{"contract_version":"2","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"}],"end_at":"2026-01-02T21:00:00Z","received_at":"2026-01-02T21:00:02Z","slice_sequence":"1","start_at":"2026-01-02T14:30:00Z"}},"record_type":"market_slice","scenario_sequence":"2"} -{"contract_version":"2","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"}],"end_at":"2026-01-05T21:00:00Z","received_at":"2026-01-05T21:00:02Z","slice_sequence":"2","start_at":"2026-01-05T14:30:00Z"}},"record_type":"market_slice","scenario_sequence":"3"} -{"contract_version":"2","payload":{"intents":[{"targets":[{"instrument_id":"demo-equity-acme","quantity":"2"}],"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"}],"end_at":"2026-01-06T21:00:00Z","received_at":"2026-01-06T21:00:02Z","slice_sequence":"3","start_at":"2026-01-06T14:30:00Z"}},"record_type":"market_slice","scenario_sequence":"4"} -{"contract_version":"2","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"}],"end_at":"2026-01-07T21:00:00Z","received_at":"2026-01-07T21:00:02Z","slice_sequence":"4","start_at":"2026-01-07T14:30:00Z"}},"record_type":"market_slice","scenario_sequence":"5"} -{"contract_version":"2","payload":{"slice_count":"4"},"record_type":"scenario_end","scenario_sequence":"6"} diff --git a/contracts/v2/journal.schema.json b/contracts/v2/journal.schema.json deleted file mode 100644 index b2f9327..0000000 --- a/contracts/v2/journal.schema.json +++ /dev/null @@ -1,431 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/fallblu/trading-engine/contracts/v2/journal.schema.json", - "title": "Trading Engine v2 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": "2" }, - "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", - "market_slice_received", - "target_portfolio_requested", - "order_accepted", - "order_rejected", - "order_cancelled", - "fill_applied", - "cash_limited", - "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": "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": { "const": "order_accepted" } } }, - "then": { "properties": { "payload": { "$ref": "#/$defs/order" } } } - }, - { - "if": { "properties": { "event_type": { "const": "order_rejected" } } }, - "then": { "properties": { "payload": { "$ref": "#/$defs/order" } } } - }, - { - "if": { "properties": { "event_type": { "const": "order_cancelled" } } }, - "then": { "properties": { "payload": { "$ref": "#/$defs/orderCancelled" } } } - }, - { - "if": { "properties": { "event_type": { "const": "fill_applied" } } }, - "then": { "properties": { "payload": { "$ref": "#/$defs/fill" } } } - }, - { - "if": { "properties": { "event_type": { "const": "cash_limited" } } }, - "then": { "properties": { "payload": { "$ref": "#/$defs/cashLimited" } } } - }, - { - "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": "valuation" } } }, - "then": { "properties": { "payload": { "$ref": "#/$defs/valuation" } } } - }, - { - "if": { "properties": { "event_type": { "const": "run_completed" } } }, - "then": { "properties": { "payload": { "$ref": "#/$defs/runCompleted" } } } - } - ], - "$defs": { - "identifier": { - "type": "string", - "minLength": 1, - "pattern": "^[^\\s\\u0000-\\u001f\\u007f]+$" - }, - "unsignedDecimal": { - "type": "string", - "pattern": "^(?:0|[1-9][0-9]*)(?:[.][0-9]{0,5}[1-9])?$" - }, - "signedDecimal": { - "type": "string", - "pattern": "^(?:0(?:[.][0-9]{0,5}[1-9])?|[1-9][0-9]*(?:[.][0-9]{0,5}[1-9])?|-(?: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])?)$" - }, - "weight": { - "type": "string", - "pattern": "^(?:0(?:[.][0-9]{0,5}[1-9])?|1)$" - }, - "quantity": { - "type": "string", - "pattern": "^(?:0|[1-9][0-9]*)$" - }, - "positiveQuantity": { "type": "string", "pattern": "^[1-9][0-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})$" - }, - "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, - "executionModel": { "const": "completed_bar_v1" }, - "runStarted": { - "type": "object", - "additionalProperties": false, - "required": ["scenario_sha256", "execution_model"], - "properties": { - "scenario_sha256": { "$ref": "#/$defs/sha256" }, - "execution_model": { "$ref": "#/$defs/executionModel" } - } - }, - "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": [ - { "$ref": "#/$defs/quantity" }, - { "type": "null" } - ] - } - } - }, - "marketSlice": { - "type": "object", - "additionalProperties": false, - "required": [ - "slice_sequence", - "start_at", - "end_at", - "available_at", - "received_at", - "bars" - ], - "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" } - } - } - }, - "requestedWeightTarget": { - "type": "object", - "additionalProperties": false, - "required": ["instrument_id", "weight", "quantity", "reference_price"], - "properties": { - "instrument_id": { "$ref": "#/$defs/identifier" }, - "weight": { "$ref": "#/$defs/weight" }, - "quantity": { "$ref": "#/$defs/quantity" }, - "reference_price": { "$ref": "#/$defs/positiveDecimal" } - } - }, - "requestedQuantityTarget": { - "type": "object", - "additionalProperties": false, - "required": ["instrument_id", "weight", "quantity", "reference_price"], - "properties": { - "instrument_id": { "$ref": "#/$defs/identifier" }, - "weight": { "type": "null" }, - "quantity": { "$ref": "#/$defs/quantity" }, - "reference_price": { "type": "null" } - } - }, - "targetPortfolio": { - "oneOf": [ - { - "type": "object", - "additionalProperties": false, - "required": ["basis", "targets"], - "properties": { - "basis": { "const": "weights" }, - "targets": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/requestedWeightTarget" } - } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": ["basis", "targets"], - "properties": { - "basis": { "const": "quantities" }, - "targets": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/requestedQuantityTarget" } - } - } - } - ] - }, - "order": { - "type": "object", - "additionalProperties": false, - "required": [ - "order_id", - "instrument_id", - "side", - "quantity", - "order_kind", - "limit_price", - "origin", - "created_event_id", - "created_sequence", - "created_at", - "eligible_after_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/positiveQuantity" }, - "order_kind": { "enum": ["market", "limit"] }, - "limit_price": { - "oneOf": [ - { "$ref": "#/$defs/positiveDecimal" }, - { "type": "null" } - ] - }, - "origin": { "enum": ["direct", "target_rebalance"] }, - "created_event_id": { "$ref": "#/$defs/identifier" }, - "created_sequence": { "$ref": "#/$defs/sequence" }, - "created_at": { "$ref": "#/$defs/timestamp" }, - "eligible_after_slice_sequence": { "$ref": "#/$defs/sequence" }, - "filled_quantity": { "$ref": "#/$defs/quantity" }, - "filled_notional": { "$ref": "#/$defs/unsignedDecimal" }, - "status": { - "enum": ["working", "partially_filled", "filled", "cancelled", "rejected"] - }, - "rejection_reason": { - "oneOf": [{ "type": "string" }, { "type": "null" }] - } - } - }, - "orderCancelled": { - "type": "object", - "additionalProperties": false, - "required": ["order", "reason"], - "properties": { - "order": { "$ref": "#/$defs/order" }, - "reason": { "enum": ["strategy_requested", "target_replaced", "market_ioc"] } - } - }, - "fill": { - "type": "object", - "additionalProperties": false, - "required": [ - "fill_id", - "order_id", - "instrument_id", - "side", - "quantity", - "price", - "notional", - "fee", - "executed_at", - "slice_sequence" - ], - "properties": { - "fill_id": { "$ref": "#/$defs/identifier" }, - "order_id": { "$ref": "#/$defs/identifier" }, - "instrument_id": { "$ref": "#/$defs/identifier" }, - "side": { "enum": ["buy", "sell"] }, - "quantity": { "$ref": "#/$defs/positiveQuantity" }, - "price": { "$ref": "#/$defs/positiveDecimal" }, - "notional": { "$ref": "#/$defs/unsignedDecimal" }, - "fee": { "$ref": "#/$defs/unsignedDecimal" }, - "executed_at": { "$ref": "#/$defs/timestamp" }, - "slice_sequence": { "$ref": "#/$defs/sequence" } - } - }, - "cashLimited": { - "type": "object", - "additionalProperties": false, - "required": [ - "order_id", - "instrument_id", - "requested_quantity", - "affordable_quantity", - "price" - ], - "properties": { - "order_id": { "$ref": "#/$defs/identifier" }, - "instrument_id": { "$ref": "#/$defs/identifier" }, - "requested_quantity": { "$ref": "#/$defs/positiveQuantity" }, - "affordable_quantity": { "$ref": "#/$defs/quantity" }, - "price": { "$ref": "#/$defs/positiveDecimal" } - } - }, - "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", "minLength": 1 }, - "value": { "type": "string" } - } - }, - "positionAttribution": { - "type": "object", - "additionalProperties": false, - "required": [ - "instrument_id", - "quantity", - "mark", - "market_value", - "cost_basis", - "realized_pnl", - "unrealized_pnl", - "total_fees" - ], - "properties": { - "instrument_id": { "$ref": "#/$defs/identifier" }, - "quantity": { "$ref": "#/$defs/quantity" }, - "mark": { "$ref": "#/$defs/positiveDecimal" }, - "market_value": { "$ref": "#/$defs/unsignedDecimal" }, - "cost_basis": { "$ref": "#/$defs/unsignedDecimal" }, - "realized_pnl": { "$ref": "#/$defs/signedDecimal" }, - "unrealized_pnl": { "$ref": "#/$defs/signedDecimal" }, - "total_fees": { "$ref": "#/$defs/unsignedDecimal" } - } - }, - "valuation": { - "type": "object", - "additionalProperties": false, - "required": [ - "cash", - "market_value", - "cost_basis", - "realized_pnl", - "unrealized_pnl", - "equity", - "total_fees", - "positions" - ], - "properties": { - "cash": { "$ref": "#/$defs/unsignedDecimal" }, - "market_value": { "$ref": "#/$defs/unsignedDecimal" }, - "cost_basis": { "$ref": "#/$defs/unsignedDecimal" }, - "realized_pnl": { "$ref": "#/$defs/signedDecimal" }, - "unrealized_pnl": { "$ref": "#/$defs/signedDecimal" }, - "equity": { "$ref": "#/$defs/unsignedDecimal" }, - "total_fees": { "$ref": "#/$defs/unsignedDecimal" }, - "positions": { - "type": "array", - "items": { "$ref": "#/$defs/positionAttribution" } - } - } - }, - "orderCounts": { - "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 } - } - }, - "runCompleted": { - "type": "object", - "additionalProperties": false, - "required": [ - "scenario_sha256", - "execution_model", - "valuation", - "order_counts" - ], - "properties": { - "scenario_sha256": { "$ref": "#/$defs/sha256" }, - "execution_model": { "$ref": "#/$defs/executionModel" }, - "valuation": { "$ref": "#/$defs/valuation" }, - "order_counts": { "$ref": "#/$defs/orderCounts" } - } - } - } -} diff --git a/contracts/v2/scenario-stream.schema.json b/contracts/v2/scenario-stream.schema.json deleted file mode 100644 index 6f63da0..0000000 --- a/contracts/v2/scenario-stream.schema.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/fallblu/trading-engine/contracts/v2/scenario-stream.schema.json", - "title": "Trading Engine v2 replay scenario stream record", - "description": "The structural contract for one v2 record in a bounded-memory JSON Lines replay scenario. The engine additionally checks record order, terminal counts, model support, catalog coverage, market ordering, causality, risk, tick, lot, time, and OHLC invariants.", - "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": "2" }, - "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": "2" }, - "scenario_sequence": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v2/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": "2" }, - "scenario_sequence": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v2/scenario.schema.json#/$defs/sequence" - }, - "record_type": { "const": "scenario_end" }, - "payload": { "$ref": "#/$defs/endPayload" } - } - }, - "headerPayload": { - "type": "object", - "additionalProperties": false, - "required": [ - "metadata", - "run_id", - "base_currency", - "initial_cash", - "instruments", - "risk", - "execution", - "max_internal_events" - ], - "properties": { - "metadata": { "type": "object" }, - "run_id": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v2/scenario.schema.json#/$defs/identifier" - }, - "base_currency": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v2/scenario.schema.json#/$defs/identifier" - }, - "initial_cash": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v2/scenario.schema.json#/$defs/unsignedDecimal" - }, - "instruments": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v2/scenario.schema.json#/$defs/instrument" - } - }, - "risk": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v2/scenario.schema.json#/$defs/risk" - }, - "execution": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v2/scenario.schema.json#/$defs/execution" - }, - "max_internal_events": { "type": "integer", "minimum": 1 } - } - }, - "slicePayload": { - "type": "object", - "additionalProperties": false, - "required": ["market_slice", "intents"], - "properties": { - "market_slice": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v2/scenario.schema.json#/$defs/marketSlice" - }, - "intents": { - "type": "array", - "items": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v2/scenario.schema.json#/$defs/intent" - } - } - } - }, - "endPayload": { - "type": "object", - "additionalProperties": false, - "required": ["slice_count"], - "properties": { - "slice_count": { - "$ref": "https://github.com/fallblu/trading-engine/contracts/v2/scenario.schema.json#/$defs/quantity" - } - } - } - } -} diff --git a/contracts/v2/scenario.schema.json b/contracts/v2/scenario.schema.json deleted file mode 100644 index 0322b8c..0000000 --- a/contracts/v2/scenario.schema.json +++ /dev/null @@ -1,316 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/fallblu/trading-engine/contracts/v2/scenario.schema.json", - "title": "Trading Engine v2 replay scenario", - "description": "The strict structural input contract. The engine additionally checks catalog coverage, ordering, causality, risk, tick, lot, time, and OHLC invariants.", - "type": "object", - "additionalProperties": false, - "required": [ - "contract_version", - "metadata", - "run_id", - "base_currency", - "initial_cash", - "instruments", - "risk", - "execution", - "max_internal_events", - "schedule", - "slices" - ], - "properties": { - "contract_version": { "const": "2" }, - "metadata": { "type": "object" }, - "run_id": { "$ref": "#/$defs/identifier" }, - "base_currency": { "$ref": "#/$defs/identifier" }, - "initial_cash": { "$ref": "#/$defs/unsignedDecimal" }, - "instruments": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/instrument" } - }, - "risk": { "$ref": "#/$defs/risk" }, - "execution": { "$ref": "#/$defs/execution" }, - "max_internal_events": { "type": "integer", "minimum": 1 }, - "schedule": { - "type": "array", - "items": { "$ref": "#/$defs/scheduleItem" } - }, - "slices": { - "type": "array", - "items": { "$ref": "#/$defs/marketSlice" } - } - }, - "$defs": { - "identifier": { - "type": "string", - "minLength": 1, - "pattern": "^[^\\s\\u0000-\\u001f\\u007f]+$" - }, - "unsignedDecimal": { - "type": "string", - "pattern": "^(?:0|[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])?)$" - }, - "weight": { - "type": "string", - "pattern": "^(?:0(?:[.][0-9]{0,5}[1-9])?|1)$" - }, - "quantity": { - "type": "string", - "pattern": "^(?:0|[1-9][0-9]*)$" - }, - "positiveQuantity": { - "type": "string", - "pattern": "^[1-9][0-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})$" - }, - "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/positiveQuantity" } - } - }, - "risk": { - "type": "object", - "additionalProperties": false, - "required": ["max_order_quantity", "max_position"], - "properties": { - "max_order_quantity": { "$ref": "#/$defs/positiveQuantity" }, - "max_position": { "$ref": "#/$defs/positiveQuantity" } - } - }, - "execution": { - "type": "object", - "additionalProperties": false, - "required": ["model", "participation_bps", "fixed_fee", "fee_bps"], - "properties": { - "model": { "const": "completed_bar_v1" }, - "participation_bps": { - "type": "integer", - "minimum": 0, - "maximum": 10000 - }, - "fixed_fee": { "$ref": "#/$defs/unsignedDecimal" }, - "fee_bps": { "type": "integer", "minimum": 0, "maximum": 10000 } - } - }, - "scheduleItem": { - "type": "object", - "additionalProperties": false, - "required": ["after_slice_sequence", "intents"], - "properties": { - "after_slice_sequence": { "$ref": "#/$defs/sequence" }, - "intents": { - "type": "array", - "items": { "$ref": "#/$defs/intent" } - } - } - }, - "intent": { - "oneOf": [ - { "$ref": "#/$defs/targetWeightsIntent" }, - { "$ref": "#/$defs/targetQuantitiesIntent" }, - { "$ref": "#/$defs/marketOrderIntent" }, - { "$ref": "#/$defs/limitOrderIntent" }, - { "$ref": "#/$defs/cancelOrderIntent" }, - { "$ref": "#/$defs/metricIntent" } - ] - }, - "targetWeightsIntent": { - "type": "object", - "additionalProperties": false, - "required": ["type", "targets"], - "properties": { - "type": { "const": "target_weights" }, - "targets": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/weightTarget" } - } - } - }, - "weightTarget": { - "type": "object", - "additionalProperties": false, - "required": ["instrument_id", "weight"], - "properties": { - "instrument_id": { "$ref": "#/$defs/identifier" }, - "weight": { "$ref": "#/$defs/weight" } - } - }, - "targetQuantitiesIntent": { - "type": "object", - "additionalProperties": false, - "required": ["type", "targets"], - "properties": { - "type": { "const": "target_quantities" }, - "targets": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/quantityTarget" } - } - } - }, - "quantityTarget": { - "type": "object", - "additionalProperties": false, - "required": ["instrument_id", "quantity"], - "properties": { - "instrument_id": { "$ref": "#/$defs/identifier" }, - "quantity": { "$ref": "#/$defs/quantity" } - } - }, - "orderFields": { - "type": "object", - "required": [ - "type", - "instrument_id", - "side", - "quantity", - "order_kind", - "limit_price" - ], - "properties": { - "type": { "const": "submit_order" }, - "instrument_id": { "$ref": "#/$defs/identifier" }, - "side": { "enum": ["buy", "sell"] }, - "quantity": { "$ref": "#/$defs/positiveQuantity" } - } - }, - "marketOrderIntent": { - "allOf": [ - { "$ref": "#/$defs/orderFields" }, - { - "type": "object", - "additionalProperties": false, - "required": [ - "type", - "instrument_id", - "side", - "quantity", - "order_kind", - "limit_price" - ], - "properties": { - "type": true, - "instrument_id": true, - "side": true, - "quantity": true, - "order_kind": { "const": "market" }, - "limit_price": { "type": "null" } - } - } - ] - }, - "limitOrderIntent": { - "allOf": [ - { "$ref": "#/$defs/orderFields" }, - { - "type": "object", - "additionalProperties": false, - "required": [ - "type", - "instrument_id", - "side", - "quantity", - "order_kind", - "limit_price" - ], - "properties": { - "type": true, - "instrument_id": true, - "side": true, - "quantity": true, - "order_kind": { "const": "limit" }, - "limit_price": { "$ref": "#/$defs/positiveDecimal" } - } - } - ] - }, - "cancelOrderIntent": { - "type": "object", - "additionalProperties": false, - "required": ["type", "order_id"], - "properties": { - "type": { "const": "cancel_order" }, - "order_id": { "$ref": "#/$defs/identifier" } - } - }, - "metricIntent": { - "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" - ], - "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" } - } - } - }, - "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": [ - { "$ref": "#/$defs/quantity" }, - { "type": "null" } - ] - } - } - } - } -} diff --git a/contracts/v3/README.md b/contracts/v3/README.md deleted file mode 100644 index f667908..0000000 --- a/contracts/v3/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Trading Engine contract v3 - -This directory is the authoritative v3 process and file contract shared by Trading Engine and -its clients. Version 2 remains frozen under `contracts/v2`. - -- `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. - -Version 3 adds exact six-decimal quantities, signed targets and positions, explicit per-currency -cash and FX marks, splits and cash dividends, borrow costs, exposure and margin policy, and causal -margin-call/liquidation events. Every v3 scenario, stream record, and journal record carries -`"contract_version": "3"`; consumers reject missing or unsupported versions before interpreting -the remainder of a document. diff --git a/contracts/v3/dune b/contracts/v3/dune deleted file mode 100644 index 55cb489..0000000 --- a/contracts/v3/dune +++ /dev/null @@ -1,10 +0,0 @@ -(install - (section share) - (package trading_engine) - (files - (journal.schema.json as contracts/v3/journal.schema.json) - (scenario-stream.schema.json as contracts/v3/scenario-stream.schema.json) - (scenario.schema.json as contracts/v3/scenario.schema.json) - (fixtures/demo.journal.jsonl as contracts/v3/fixtures/demo.journal.jsonl) - (fixtures/demo.scenario.json as contracts/v3/fixtures/demo.scenario.json) - (fixtures/demo.scenario.jsonl as contracts/v3/fixtures/demo.scenario.jsonl))) diff --git a/contracts/v3/fixtures/demo.journal.jsonl b/contracts/v3/fixtures/demo.journal.jsonl deleted file mode 100644 index 475885a..0000000 --- a/contracts/v3/fixtures/demo.journal.jsonl +++ /dev/null @@ -1,20 +0,0 @@ -{"contract_version":"3","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":"3e19fa66bc6425bb8ed7a89b338080a831dd39ea778c3c7f9e8ce1d3370fbee0","execution_model":"completed_bar_v1"}} -{"contract_version":"3","engine_sequence":"2","event_id":"demo-event-000000000002","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":[]}} -{"contract_version":"3","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":"target_portfolio_requested","payload":{"basis":"weights","targets":[{"instrument_id":"demo-equity-acme","weight":"0.1","quantity":"9.615","reference_price":"104"}]}} -{"contract_version":"3","engine_sequence":"4","event_id":"demo-event-000000000004","causation_ids":["demo-event-000000000002"],"run_id":"demo","recorded_at":"2026-01-02T21:00:02.000000Z","event_type":"metric_emitted","payload":{"name":"desired_weight","value":"0.1"}} -{"contract_version":"3","engine_sequence":"5","event_id":"demo-event-000000000005","causation_ids":["demo-event-000000000002","demo-event-000000000003"],"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":"9.615","order_kind":"market","limit_price":null,"origin":"target_rebalance","created_event_id":"demo-event-000000000005","updated_event_id":"demo-event-000000000005","created_sequence":"5","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","filled_quantity":"0","filled_notional":"0","status":"working","rejection_reason":null}} -{"contract_version":"3","engine_sequence":"6","event_id":"demo-event-000000000006","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":"0","long_market_value":"0","short_market_value":"0","gross_exposure":"0","cost_basis":"0","realized_pnl":"0","unrealized_pnl":"0","equity":"10000","dividend_pnl":"0","execution_fees":"0","borrow_fees":"0","total_fees":"0","cash_balances":[{"currency":"USD","amount":"10000","fx_rate":"1","base_value":"10000"}],"positions":[{"instrument_id":"demo-equity-acme","quote_currency":"USD","quantity":"0","mark":"104","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"}],"margin":{"initial_requirement":"0","maintenance_requirement":"0","initial_excess":"10000","maintenance_excess":"10000","margin_call":false}}} -{"contract_version":"3","engine_sequence":"7","event_id":"demo-event-000000000007","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":[]}} -{"contract_version":"3","engine_sequence":"8","event_id":"demo-event-000000000008","causation_ids":["demo-event-000000000005","demo-event-000000000007"],"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"}} -{"contract_version":"3","engine_sequence":"9","event_id":"demo-event-000000000009","causation_ids":["demo-event-000000000005","demo-event-000000000007"],"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":"9.615","order_kind":"market","limit_price":null,"origin":"target_rebalance","created_event_id":"demo-event-000000000005","updated_event_id":"demo-event-000000000005","created_sequence":"5","created_at":"2026-01-02T21:00:02.000000Z","eligible_after_slice_sequence":"1","filled_quantity":"6","filled_notional":"618","status":"cancelled","rejection_reason":null},"reason":"market_ioc"}} -{"contract_version":"3","engine_sequence":"10","event_id":"demo-event-000000000010","causation_ids":["demo-event-000000000003","demo-event-000000000007"],"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":"3.615","order_kind":"market","limit_price":null,"origin":"target_rebalance","created_event_id":"demo-event-000000000010","updated_event_id":"demo-event-000000000010","created_sequence":"10","created_at":"2026-01-05T21:00:02.000000Z","eligible_after_slice_sequence":"2","filled_quantity":"0","filled_notional":"0","status":"working","rejection_reason":null}} -{"contract_version":"3","engine_sequence":"11","event_id":"demo-event-000000000011","causation_ids":["demo-event-000000000007"],"run_id":"demo","recorded_at":"2026-01-05T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"9381.132","net_market_value":"642","long_market_value":"642","short_market_value":"0","gross_exposure":"642","cost_basis":"618.868","realized_pnl":"0","unrealized_pnl":"23.132","equity":"10023.132","dividend_pnl":"0","execution_fees":"0.868","borrow_fees":"0","total_fees":"0.868","cash_balances":[{"currency":"USD","amount":"9381.132","fx_rate":"1","base_value":"9381.132"}],"positions":[{"instrument_id":"demo-equity-acme","quote_currency":"USD","quantity":"6","mark":"107","fx_rate":"1","market_value":"642","base_market_value":"642","cost_basis":"618.868","base_cost_basis":"618.868","realized_pnl":"0","base_realized_pnl":"0","unrealized_pnl":"23.132","base_unrealized_pnl":"23.132","dividend_pnl":"0","base_dividend_pnl":"0","execution_fees":"0.868","base_execution_fees":"0.868","borrow_fees":"0","base_borrow_fees":"0","total_fees":"0.868","base_total_fees":"0.868"}],"margin":{"initial_requirement":"321","maintenance_requirement":"160.5","initial_excess":"9702.132","maintenance_excess":"9862.632","margin_call":false}}} -{"contract_version":"3","engine_sequence":"12","event_id":"demo-event-000000000012","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":[]}} -{"contract_version":"3","engine_sequence":"13","event_id":"demo-event-000000000013","causation_ids":["demo-event-000000000010","demo-event-000000000012"],"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":"3.615","price":"107","notional":"386.805","fee":"0.636805","executed_at":"2026-01-06T14:30:00.000000Z","slice_sequence":"3"}} -{"contract_version":"3","engine_sequence":"14","event_id":"demo-event-000000000014","causation_ids":["demo-event-000000000012"],"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":"3","engine_sequence":"15","event_id":"demo-event-000000000015","causation_ids":["demo-event-000000000012","demo-event-000000000014"],"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.115","order_kind":"market","limit_price":null,"origin":"target_rebalance","created_event_id":"demo-event-000000000015","updated_event_id":"demo-event-000000000015","created_sequence":"15","created_at":"2026-01-06T21:00:02.000000Z","eligible_after_slice_sequence":"3","filled_quantity":"0","filled_notional":"0","status":"working","rejection_reason":null}} -{"contract_version":"3","engine_sequence":"16","event_id":"demo-event-000000000016","causation_ids":["demo-event-000000000012"],"run_id":"demo","recorded_at":"2026-01-06T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"8993.690195","net_market_value":"1009.575","long_market_value":"1009.575","short_market_value":"0","gross_exposure":"1009.575","cost_basis":"1006.309805","realized_pnl":"0","unrealized_pnl":"3.265195","equity":"10003.265195","dividend_pnl":"0","execution_fees":"1.504805","borrow_fees":"0","total_fees":"1.504805","cash_balances":[{"currency":"USD","amount":"8993.690195","fx_rate":"1","base_value":"8993.690195"}],"positions":[{"instrument_id":"demo-equity-acme","quote_currency":"USD","quantity":"9.615","mark":"105","fx_rate":"1","market_value":"1009.575","base_market_value":"1009.575","cost_basis":"1006.309805","base_cost_basis":"1006.309805","realized_pnl":"0","base_realized_pnl":"0","unrealized_pnl":"3.265195","base_unrealized_pnl":"3.265195","dividend_pnl":"0","base_dividend_pnl":"0","execution_fees":"1.504805","base_execution_fees":"1.504805","borrow_fees":"0","base_borrow_fees":"0","total_fees":"1.504805","base_total_fees":"1.504805"}],"margin":{"initial_requirement":"504.7875","maintenance_requirement":"252.39375","initial_excess":"9498.477695","maintenance_excess":"9750.871445","margin_call":false}}} -{"contract_version":"3","engine_sequence":"17","event_id":"demo-event-000000000017","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":[]}} -{"contract_version":"3","engine_sequence":"18","event_id":"demo-event-000000000018","causation_ids":["demo-event-000000000015","demo-event-000000000017"],"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.115","price":"105","notional":"747.075","fee":"0.997075","executed_at":"2026-01-07T14:30:00.000000Z","slice_sequence":"4"}} -{"contract_version":"3","engine_sequence":"19","event_id":"demo-event-000000000019","causation_ids":["demo-event-000000000017"],"run_id":"demo","recorded_at":"2026-01-07T21:00:02.000000Z","event_type":"valuation","payload":{"base_currency":"USD","cash":"9739.76812","net_market_value":"265","long_market_value":"265","short_market_value":"0","gross_exposure":"265","cost_basis":"261.651016","realized_pnl":"1.419136","unrealized_pnl":"3.348984","equity":"10004.76812","dividend_pnl":"0","execution_fees":"2.50188","borrow_fees":"0","total_fees":"2.50188","cash_balances":[{"currency":"USD","amount":"9739.76812","fx_rate":"1","base_value":"9739.76812"}],"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":"261.651016","base_cost_basis":"261.651016","realized_pnl":"1.419136","base_realized_pnl":"1.419136","unrealized_pnl":"3.348984","base_unrealized_pnl":"3.348984","dividend_pnl":"0","base_dividend_pnl":"0","execution_fees":"2.50188","base_execution_fees":"2.50188","borrow_fees":"0","base_borrow_fees":"0","total_fees":"2.50188","base_total_fees":"2.50188"}],"margin":{"initial_requirement":"132.5","maintenance_requirement":"66.25","initial_excess":"9872.26812","maintenance_excess":"9938.51812","margin_call":false}}} -{"contract_version":"3","engine_sequence":"20","event_id":"demo-event-000000000020","causation_ids":["demo-event-000000000019"],"run_id":"demo","recorded_at":"2026-01-07T21:00:02.000000Z","event_type":"run_completed","payload":{"scenario_sha256":"3e19fa66bc6425bb8ed7a89b338080a831dd39ea778c3c7f9e8ce1d3370fbee0","execution_model":"completed_bar_v1","valuation":{"base_currency":"USD","cash":"9739.76812","net_market_value":"265","long_market_value":"265","short_market_value":"0","gross_exposure":"265","cost_basis":"261.651016","realized_pnl":"1.419136","unrealized_pnl":"3.348984","equity":"10004.76812","dividend_pnl":"0","execution_fees":"2.50188","borrow_fees":"0","total_fees":"2.50188","cash_balances":[{"currency":"USD","amount":"9739.76812","fx_rate":"1","base_value":"9739.76812"}],"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":"261.651016","base_cost_basis":"261.651016","realized_pnl":"1.419136","base_realized_pnl":"1.419136","unrealized_pnl":"3.348984","base_unrealized_pnl":"3.348984","dividend_pnl":"0","base_dividend_pnl":"0","execution_fees":"2.50188","base_execution_fees":"2.50188","borrow_fees":"0","base_borrow_fees":"0","total_fees":"2.50188","base_total_fees":"2.50188"}],"margin":{"initial_requirement":"132.5","maintenance_requirement":"66.25","initial_excess":"9872.26812","maintenance_excess":"9938.51812","margin_call":false}},"order_counts":{"total":3,"active":0,"filled":2,"rejected":0,"cancelled":1}}} diff --git a/contracts/v3/fixtures/demo.scenario.json b/contracts/v3/fixtures/demo.scenario.json deleted file mode 100644 index d6ac1fa..0000000 --- a/contracts/v3/fixtures/demo.scenario.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "contract_version": "3", - "metadata": { - "producer": "trading-engine-demo", - "purpose": "deterministic conformance fixture" - }, - "run_id": "demo", - "base_currency": "USD", - "initial_cash": [ - { "currency": "USD", "amount": "10000" } - ], - "instruments": [ - { - "instrument_id": "demo-equity-acme", - "symbol": "ACME", - "quote_currency": "USD", - "tick_size": "0.01", - "lot_size": "0.001" - } - ], - "risk": { - "max_order_quantity": "1000", - "max_long_position": "1000", - "max_short_position": "1000", - "max_gross_exposure": "1000000", - "max_leverage": "2", - "initial_margin_bps": 5000, - "maintenance_margin_bps": 2500, - "short_borrow_bps": 100 - }, - "execution": { - "model": "completed_bar_v1", - "participation_bps": 5000, - "fixed_fee": "0.25", - "fee_bps": 10 - }, - "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": [] - }, - { - "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": [] - }, - { - "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": [] - }, - { - "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": [] - } - ] -} diff --git a/contracts/v3/fixtures/demo.scenario.jsonl b/contracts/v3/fixtures/demo.scenario.jsonl deleted file mode 100644 index 09afea0..0000000 --- a/contracts/v3/fixtures/demo.scenario.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"contract_version":"3","payload":{"base_currency":"USD","execution":{"fee_bps":10,"fixed_fee":"0.25","model":"completed_bar_v1","participation_bps":5000},"initial_cash":[{"amount":"10000","currency":"USD"}],"instruments":[{"instrument_id":"demo-equity-acme","lot_size":"0.001","quote_currency":"USD","symbol":"ACME","tick_size":"0.01"}],"max_internal_events":1000,"metadata":{"producer":"trading-engine-demo","purpose":"deterministic conformance fixture"},"risk":{"initial_margin_bps":5000,"maintenance_margin_bps":2500,"max_gross_exposure":"1000000","max_leverage":"2","max_long_position":"1000","max_order_quantity":"1000","max_short_position":"1000","short_borrow_bps":100},"run_id":"demo"},"record_type":"scenario_header","scenario_sequence":"1"} -{"contract_version":"3","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"}},"record_type":"market_slice","scenario_sequence":"2"} -{"contract_version":"3","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"}},"record_type":"market_slice","scenario_sequence":"3"} -{"contract_version":"3","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"}},"record_type":"market_slice","scenario_sequence":"4"} -{"contract_version":"3","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"}},"record_type":"market_slice","scenario_sequence":"5"} -{"contract_version":"3","payload":{"slice_count":"4"},"record_type":"scenario_end","scenario_sequence":"6"} diff --git a/contracts/v3/journal.schema.json b/contracts/v3/journal.schema.json deleted file mode 100644 index f6027d8..0000000 --- a/contracts/v3/journal.schema.json +++ /dev/null @@ -1,151 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/fallblu/trading-engine/contracts/v3/journal.schema.json", - "title": "Trading Engine v3 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": "3" }, - "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", "market_slice_received", "target_portfolio_requested", "order_accepted", "order_rejected", "order_cancelled", "split_applied", "cash_dividend_applied", "order_adjusted", "fill_applied", "margin_limited", "borrow_fee_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": "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"] } } }, "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": "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": { "const": "margin_limited" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/marginLimited" } } } }, - { "if": { "properties": { "event_type": { "const": "borrow_fee_applied" } } }, "then": { "properties": { "payload": { "$ref": "#/$defs/borrowFee" } } } }, - { "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" } } - }, - "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" } } } - ] - }, - "marketSlice": { - "type": "object", "additionalProperties": false, - "required": ["slice_sequence", "start_at", "end_at", "available_at", "received_at", "bars", "fx_rates", "corporate_actions"], - "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" } } - } - }, - "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", "limit_price", "origin", "created_event_id", "updated_event_id", "created_sequence", "created_at", "eligible_after_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"] }, "limit_price": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/positiveDecimal" }] }, "origin": { "enum": ["direct", "target_rebalance", "margin_liquidation"] }, "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" }, "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", "margin_call"] } } - }, - "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" } } - }, - "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"], - "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/unsignedDecimal" }, "executed_at": { "$ref": "#/$defs/timestamp" }, "slice_sequence": { "$ref": "#/$defs/sequence" } } - }, - "marginLimited": { - "type": "object", "additionalProperties": false, "required": ["order_id", "instrument_id", "requested_quantity", "permitted_quantity", "price"], - "properties": { "order_id": { "$ref": "#/$defs/identifier" }, "instrument_id": { "$ref": "#/$defs/identifier" }, "requested_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" } } - }, - "cashAttribution": { - "type": "object", "additionalProperties": false, "required": ["currency", "amount", "fx_rate", "base_value"], - "properties": { "currency": { "$ref": "#/$defs/identifier" }, "amount": { "$ref": "#/$defs/signedDecimal" }, "fx_rate": { "$ref": "#/$defs/positiveDecimal" }, "base_value": { "$ref": "#/$defs/signedDecimal" } } - }, - "positionAttribution": { - "type": "object", "additionalProperties": false, - "required": ["instrument_id", "quote_currency", "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"], - "properties": { - "instrument_id": { "$ref": "#/$defs/identifier" }, "quote_currency": { "$ref": "#/$defs/identifier" }, "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/unsignedDecimal" }, "base_execution_fees": { "$ref": "#/$defs/unsignedDecimal" }, "borrow_fees": { "$ref": "#/$defs/unsignedDecimal" }, "base_borrow_fees": { "$ref": "#/$defs/unsignedDecimal" }, "total_fees": { "$ref": "#/$defs/unsignedDecimal" }, "base_total_fees": { "$ref": "#/$defs/unsignedDecimal" } - } - }, - "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" } } - }, - "valuation": { - "type": "object", "additionalProperties": false, - "required": ["base_currency", "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", "total_fees", "cash_balances", "positions", "margin"], - "properties": { - "base_currency": { "$ref": "#/$defs/identifier" }, "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/unsignedDecimal" }, "borrow_fees": { "$ref": "#/$defs/unsignedDecimal" }, "total_fees": { "$ref": "#/$defs/unsignedDecimal" }, "cash_balances": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/cashAttribution" } }, "positions": { "type": "array", "items": { "$ref": "#/$defs/positionAttribution" } }, "margin": { "$ref": "#/$defs/margin" } - } - }, - "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/v3/scenario-stream.schema.json b/contracts/v3/scenario-stream.schema.json deleted file mode 100644 index 913ff1d..0000000 --- a/contracts/v3/scenario-stream.schema.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/fallblu/trading-engine/contracts/v3/scenario-stream.schema.json", - "title": "Trading Engine v3 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": "3" }, - "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": "3" }, - "scenario_sequence": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/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": "3" }, - "scenario_sequence": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/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_cash", "instruments", "risk", "execution", "max_internal_events"], - "properties": { - "metadata": { "type": "object" }, - "run_id": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/identifier" }, - "base_currency": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/identifier" }, - "initial_cash": { "type": "array", "minItems": 1, "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/cashBalance" } }, - "instruments": { "type": "array", "minItems": 1, "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/instrument" } }, - "risk": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/risk" }, - "execution": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/execution" }, - "max_internal_events": { "type": "integer", "minimum": 1, "maximum": 4611686018427387903 } - } - }, - "slicePayload": { - "type": "object", - "additionalProperties": false, - "required": ["market_slice", "intents"], - "properties": { - "market_slice": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/marketSlice" }, - "intents": { "type": "array", "items": { "$ref": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json#/$defs/intent" } } - } - } - } -} diff --git a/contracts/v3/scenario.schema.json b/contracts/v3/scenario.schema.json deleted file mode 100644 index ec76093..0000000 --- a/contracts/v3/scenario.schema.json +++ /dev/null @@ -1,262 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/fallblu/trading-engine/contracts/v3/scenario.schema.json", - "title": "Trading Engine v3 replay scenario", - "description": "Strict deterministic scenario contract for fractional quantities, explicit FX, corporate actions, signed positions, and margin risk.", - "type": "object", - "additionalProperties": false, - "required": ["contract_version", "metadata", "run_id", "base_currency", "initial_cash", "instruments", "risk", "execution", "max_internal_events", "schedule", "slices"], - "properties": { - "contract_version": { "const": "3" }, - "metadata": { "type": "object" }, - "run_id": { "$ref": "#/$defs/identifier" }, - "base_currency": { "$ref": "#/$defs/identifier" }, - "initial_cash": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/cashBalance" } - }, - "instruments": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/instrument" } - }, - "risk": { "$ref": "#/$defs/risk" }, - "execution": { "$ref": "#/$defs/execution" }, - "max_internal_events": { "type": "integer", "minimum": 1, "maximum": 4611686018427387903 }, - "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": { - "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/unsignedDecimal" } - } - }, - "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" } - } - }, - "risk": { - "type": "object", - "additionalProperties": false, - "required": ["max_order_quantity", "max_long_position", "max_short_position", "max_gross_exposure", "max_leverage", "initial_margin_bps", "maintenance_margin_bps", "short_borrow_bps"], - "properties": { - "max_order_quantity": { "$ref": "#/$defs/positiveDecimal" }, - "max_long_position": { "$ref": "#/$defs/positiveDecimal" }, - "max_short_position": { "$ref": "#/$defs/positiveDecimal" }, - "max_gross_exposure": { "$ref": "#/$defs/positiveDecimal" }, - "max_leverage": { "$ref": "#/$defs/positiveDecimal" }, - "initial_margin_bps": { "type": "integer", "minimum": 1, "maximum": 10000 }, - "maintenance_margin_bps": { "type": "integer", "minimum": 1, "maximum": 10000 }, - "short_borrow_bps": { "type": "integer", "minimum": 0, "maximum": 10000 } - } - }, - "execution": { - "type": "object", - "additionalProperties": false, - "required": ["model", "participation_bps", "fixed_fee", "fee_bps"], - "properties": { - "model": { "const": "completed_bar_v1" }, - "participation_bps": { "type": "integer", "minimum": 0, "maximum": 10000 }, - "fixed_fee": { "$ref": "#/$defs/unsignedDecimal" }, - "fee_bps": { "type": "integer", "minimum": 0, "maximum": 10000 } - } - }, - "scheduleItem": { - "type": "object", - "additionalProperties": false, - "required": ["after_slice_sequence", "intents"], - "properties": { - "after_slice_sequence": { "$ref": "#/$defs/sequence" }, - "intents": { "type": "array", "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", "limit_price"], - "properties": { - "type": { "const": "submit_order" }, - "instrument_id": { "$ref": "#/$defs/identifier" }, - "side": { "enum": ["buy", "sell"] }, - "quantity": { "$ref": "#/$defs/positiveDecimal" }, - "order_kind": { "enum": ["market", "limit"] }, - "limit_price": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/positiveDecimal" }] } - } - }, - "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"], - "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" } } - } - }, - "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" } - } - } - ] - } - } -} diff --git a/coverage/ocaml-policy.json b/coverage/ocaml-policy.json new file mode 100644 index 0000000..ddc5b3b --- /dev/null +++ b/coverage/ocaml-policy.json @@ -0,0 +1,12 @@ +{ + "format_version": 1, + "minimum_coverage_percent": 86.0, + "threshold_history": [ + { + "minimum_coverage_percent": 86.0, + "reason": "Initial floor after covering bar and corporate-action validation boundaries.", + "issue_url": "https://github.com/fallblu/trading-engine/issues/17" + } + ], + "excluded_paths": [] +} diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..090341d --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,10 @@ +# OCaml API reference + +The public OCaml interfaces are generated from the checked-in `.mli` files with `odoc`. The +documentation build inserts a link to the generated module index at the marker below and verifies +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/v1/README.md) remain authoritative for process boundaries. diff --git a/docs/architecture.md b/docs/architecture.md index 2e92b15..afb946d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,18 +17,46 @@ journal files, and the runtime shell. | `Execution`, `Execution_model` | Pluggable synchronized-slice matching, capacity allocation, and fees | | `Account` | Currency ledgers, signed positions, attribution, average cost, fees, P&L, and valuation | | `Engine` | Sequencing, portfolio reconciliation, and pure suspend/resume orchestration | -| `Scenario`, `Scenario_stream`, `Replay` | Strict batch and bounded-memory scripted runners | +| `Scenario_shape` | Exact batch, stream-header, and stream-item JSON fields | +| `Scenario` | Domain construction shared by batch and stream inputs | +| `Scenario_validation` | Shared cross-field and cross-record scenario invariants | +| `Scenario_stream`, `Replay` | Bounded-memory stream adaptation and scripted runners | | `Strategy_protocol`, `Strategy_process`, `External_replay` | Versioned child supervision and external runners | -| `Sha256`, `Codec`, `Journal`, `Strategy_transcript` | Input identity, stable audit JSON, and file publication | +| `Sha256`, `Codec`, `Diagnostic`, `Artifact_writer`, `Journal`, `Strategy_transcript` | Input identity, stable diagnostics and audit JSON, and file publication | + +Boundary failures use the versioned [diagnostic contract](diagnostics.md). Pure domain constructors +and reducer internals keep plain errors inside the deterministic boundary; replay adapters attach +stable codes, phases, source locations, event causality, and sanitized exception details before +returning an error to callers. + +Batch and stream headers use the same domain construction and static semantic checks. Stream +items reuse the batch slice, intent, timeline, and catalog validators against the prior item; +they do not construct temporary batch documents. Shape, construction, and semantic errors retain +their precise JSON path, while the stream adapter adds the record line and sequence. + +The journal and strategy transcript share one typed-state artifact lifecycle for exclusive staging, +append, close, no-replace publication, and cleanup. Artifact writers and the process supervisor route +their minimal operating-system operations through one boundary dispatcher. Production executes +those effects directly. Failure-path tests replace one operation at a time, including partial +writes, without introducing files, pipes, processes, or fault state into the reducer. ## Reducer phases +The private `Reducer_phases` transition contract gives each phase an opaque immutable state input +and an explicit result. Validation, initialization, corporate actions, borrow accrual, +notifications, matching, target reconciliation, margin, and valuation are separate phase modules. +The transition machine is the only layer that selects the next phase or resumes a suspended +strategy request; individual phase modules cannot select, skip, or reorder their neighbors. +`Engine` retains the existing scripted and interactive APIs while supplying the domain operations +behind that contract. + 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 @@ -71,6 +99,10 @@ Schedule sequences and slice sequences are positive and strictly increasing. Eve anchors to an existing slice. A scheduled order-changing intent must be received no later than the next slice start. +Batch validation indexes each slice together with its successor in an ordered map. Building that +index costs `O(s log s)` for `s` slices, and each of the `m` scheduled sequence lookups costs +`O(log s)`. Validation does not rescan the slice list for each schedule entry. + ## Determinism Determinism depends on: @@ -92,7 +124,7 @@ Running the same scenario bytes produces byte-identical audit lines. `Engine.Interactive` stops at each strategy request and exposes the immutable context and event. Its `resume` transition accepts typed intents and continues the same pure reducer. The scripted runner invokes an in-process callback at that boundary. The external runner serializes it through -protocol v3. Reducer state never contains a process, clock, pipe, timeout, or file handle. +protocol v1. Reducer state never contains a process, clock, pipe, timeout, or file handle. Each strategy callback carries an account valuation built at that reducer boundary. All callbacks for a slice use its receipt time, completed bars, and FX vector. A callback response is reduced @@ -100,15 +132,31 @@ before any later callback or eligible order, so the next context exposes its eff Positive-equity accounts expose realized portfolio weights; zero- and negative-equity accounts explicitly omit weights. +Reducer feedback uses an immutable two-list queue. Adding generated notifications to the tail and +removing the next item are amortized constant-time operations. Prepending one callback's response +costs only the size of that response. Queue representation changes do not affect processing order +or the exact `max_internal_events` count. + The JSON Lines runner hashes and validates the complete stream before journal creation. It then replays one slice-plus-intents record at a time and does not accumulate market slices, schedule maps, or audit events. A required terminal record distinguishes completion from truncation. External replay requires an empty batch schedule or empty streamed intent batches. The effectful supervisor launches an explicit argument vector, permits one request at a time, enforces a -per-exchange timeout and 1 MiB response limit, then requires a clean child exit with no extra -standard output. It records every accepted request and response in sequence. Failures preserve -the transcript and journal partials; success publishes both requested paths without replacement. +per-exchange timeout and 1 MiB message limit, then requires a clean child exit with no extra +standard output. A response may contain at most 4,096 intents. It records every accepted request +and response in sequence. Failures preserve +the transcript and journal partials. After both writers close, publication links every final path +without replacement before moving any partial path to a reserved cleanup name. Only after every +move succeeds does the transaction unlink those cleanup names. A close or link failure rolls back +final links created by the transaction. A move or cleanup failure keeps the complete final set and +restores every partial name. + +Buffered publication flushes every record. Durable publication also synchronizes each staged file +before close, synchronizes each containing directory after all final links exist, removes partial +links, and synchronizes the directories again. Unsupported file or directory synchronization is an +artifact failure. The failure path rolls back an unpublished final set or restores partial names +beside an already complete final set. ## Invariants diff --git a/docs/continuous-integration.md b/docs/continuous-integration.md new file mode 100644 index 0000000..cd6e70a --- /dev/null +++ b/docs/continuous-integration.md @@ -0,0 +1,21 @@ +# Continuous integration + +CI tests the supported OCaml and dependency range without creating a Cartesian product. + +| Cell | Platform | Dependencies | Gate | +| --- | --- | --- | --- | +| `check` | Ubuntu | Repository lock | Full `make check` | +| `lowest-ubuntu` | Ubuntu | Oldest declared versions | Dependency-band check | +| `highest-ubuntu` | Ubuntu | Newest declared versions | Dependency-band check | +| `highest-macos` | macOS | Newest declared versions | Build and journal comparison | + +Required Ubuntu jobs validate contract v1 schemas and fixtures, OCaml tests, protocol fuzzing, +deterministic journals, metadata, documentation, and benchmark smoke workloads. Coverage runs once +against the locked environment. + +The required Persistra job uses a full pinned commit. A manually dispatched moving-head job is +informational and may fail without changing the supported revision pair. + +Pull requests own feature-branch validation and cancel superseded runs. Push validation runs on +`develop` and release tags without cancellation so integration and publication evidence is +retained. diff --git a/docs/coverage.md b/docs/coverage.md new file mode 100644 index 0000000..ceeb1f4 --- /dev/null +++ b/docs/coverage.md @@ -0,0 +1,50 @@ +# OCaml coverage + +The OCaml coverage gate instruments the production library and command-line executable with +Bisect_ppx while running the normal Dune test aliases. Normal `make build`, `make test`, and +`make check` targets remain uninstrumented, so coverage cannot change deterministic journals, +transcripts, diagnostics, or other contract output. + +The repository-local opam package source selects one upstream Bisect_ppx commit that supports the +project's OCaml 5.5 and ppxlib toolchain and verifies its archive with SHA-256. Bootstrap registers +that package source before installing the lock. The environment check verifies the package source +and locked dependency versions, preventing a local fallback to an incompatible release. + +Run the gate from a bootstrapped development environment: + +```sh +make coverage +``` + +The command recreates the isolated `_build-coverage/` and `_coverage/` directories, then writes +three views of the same run. A fresh isolated build ensures every test executable and cram +invocation contributes new instrumentation data without changing the normal `_build/` tree or +source-root editor artifacts: + +- `summary.txt` lists every production module and the project-wide instrumented-point result. +- `html/index.html` highlights expression and control-flow points, making unvisited match arms, + conditions, and exception paths directly inspectable. +- `cobertura.xml` provides line-oriented machine-readable data for CI and external analysis. + +CI publishes these files as the `ocaml-coverage` artifact for 14 days. The report command uses +`--expect bin/` and `--expect lib/`, so a production module that silently disappears from the +instrumented report fails the job. + +## Threshold policy + +`coverage/ocaml-policy.json` records the active project-wide minimum and its complete change +history. Every entry requires both an explanation and a repository issue. A lower minimum must be +appended as a new explained history entry; editing the active number alone fails the policy check. +Raise the minimum when sustained coverage permits it. + +There are currently no excluded production paths. Tests, generated build files, vendored +dependencies, schemas, and Python validation tools are outside the OCaml instrumentation scope; +they are exercised by the same Dune aliases but do not contribute points. If a production +expression must use `[@coverage off]` or a production path must be excluded later, add the path and +a concrete reason to `excluded_paths` in the policy before using the exclusion. Do not exclude +defensive failures merely because they are difficult to trigger. + +The initial floor was measured only after adding focused tests for bar price/volume invariants and +corporate-action validation, comparison, and rendering. These paths guard market-data integrity +and split/dividend semantics, so their coverage was addressed before adopting the project-wide +minimum. diff --git a/docs/diagnostics.md b/docs/diagnostics.md new file mode 100644 index 0000000..537b92e --- /dev/null +++ b/docs/diagnostics.md @@ -0,0 +1,52 @@ +# Diagnostics + +Process and file boundaries return diagnostic contract version `1`. The CLI prints the concise +`message` by default. Pass `--diagnostic-format json` to write one machine-readable diagnostic to +standard error. `--output-format json` also selects JSON diagnostics so an automation client needs +only one format option. The process exits with status 123 for either format. + +The versioned [diagnostic JSON Schema](../contracts/diagnostic/v1/diagnostic.schema.json) is the +authoritative structural contract. The adjacent fixture demonstrates every optional context +field. Strategy rejection records reference this schema instead of copying its field definitions. + +Every JSON diagnostic contains: + +| Field | Type | Meaning | +| --- | --- | --- | +| `diagnostic_version` | string | Diagnostic contract version | +| `code` | string | Stable machine classification | +| `phase` | string | `cli`, `input`, `validation`, `replay`, `reducer`, `strategy`, or `artifact` | +| `message` | string | Concise human description; clients must not parse it | +| `context` | object | Known location and causality fields | +| `cause` | object or null | Sanitized underlying exception | + +Context fields are omitted when unknown. `json_path`, `event_id`, and `order_id` are strings; +`line` is a JSON integer; `sequence` is a canonical int64 string; and `causation_ids` is an ordered +array of event ID strings. A cause contains `kind` and `message`, plus `operation` and `target` for +Unix errors. Diagnostics retain no input record, strategy message, or unrelated payload data. + +Version 1 defines these codes: + +| Code | Meaning | +| --- | --- | +| `cli.invalid_arguments` | Runtime option combination is invalid | +| `input.io` | Input open, read, or hash operation failed | +| `scenario.invalid_json` | Scenario or strategy JSON syntax is invalid | +| `scenario.invalid` | Batch scenario validation failed | +| `scenario.unsupported_contract` | Scenario contract version is unsupported | +| `scenario_stream.invalid` | Stream envelope, ordering, or payload validation failed | +| `scenario_stream.changed` | Stream bytes changed between validation and replay | +| `resource.limit` | A versioned parser, protocol, reducer, or artifact limit was exceeded | +| `replay.failed` | Replay orchestration invariant failed | +| `reducer.failed` | Pure engine processing rejected the requested transition | +| `strategy.invalid_configuration` | Strategy command or timeout is invalid | +| `strategy.protocol` | Strategy exchange violated the protocol | +| `strategy.timeout` | Strategy exchange or shutdown exceeded its deadline | +| `strategy.process` | Strategy spawn, signaling, supervision, or process I/O failed | +| `strategy.exit` | Strategy exited with an unsuccessful status | +| `artifact.exists` | A final or partial artifact path already exists | +| `artifact.io` | Artifact creation, append, synchronization, close, publication, or cleanup failed | +| `artifact.state` | Artifact writer lifecycle operation is invalid | + +Adding codes or optional context fields does not change the diagnostic version. Removing a code, +changing a field type, or changing a code's meaning requires a new version. diff --git a/docs/documentation-platform.md b/docs/documentation-platform.md new file mode 100644 index 0000000..87c152e --- /dev/null +++ b/docs/documentation-platform.md @@ -0,0 +1,38 @@ +# Documentation platform + +The canonical documentation site is published at `https://fallblu.github.io/trading-engine/` from +one immutable GitHub Pages artifact. It combines project guides, versioned contract sources, and +generated OCaml API pages without committing generated HTML. + +## Toolchain + +[MkDocs](https://www.mkdocs.org/user-guide/configuration/) 1.6 and Material for MkDocs 9 render the +Markdown navigation and search index. [odoc](https://ocaml.github.io/odoc/odoc/odoc_for_authors.html) +renders the public `.mli` interfaces through Dune's `@doc` target. Exact Python package versions +live in `requirements/docs.lock`; the exact odoc version lives in `trading_engine.opam.locked`. + +The build stages the repository Markdown and entire `contracts/` tree under `_build`, adds the odoc +HTML tree, and then runs `mkdocs build --strict`. Staging publishes contract README files, schemas, +and fixtures directly from their source locations, so the v1 pages cannot diverge from the +repository copies. + +`make docs-check` performs the deterministic offline source check. `make docs-build` bootstraps the +locked documentation tools, builds odoc, runs strict MkDocs, checks every generated local link, and +confirms that public modules and contract assets are present. + +## Deployment boundary + +The [GitHub Pages custom workflow](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages) +uses a read-only build job. Pull requests build and package the complete site but cannot deploy it. +Only a push to `develop` enables the separate deployment job, whose only elevated permissions are +`pages: write` and `id-token: write`. The `github-pages` environment records the deployed URL. + +All actions use full commit pins. Pages deployment never writes a generated branch, repository +commit, tag, or release artifact. + +## Link validation + +Offline checks validate every repository-relative Markdown target and every generated HTML link. +External HTTPS links run through a separate pinned Lychee workflow on relevant pull requests, +`develop` changes, a weekly schedule, and manual dispatch. It uses no token, rejects insecure or +private targets, bounds redirects and retries, and begins with an empty exception list. diff --git a/docs/execution-model.md b/docs/execution-model.md index 95cb4c0..745c039 100644 --- a/docs/execution-model.md +++ b/docs/execution-model.md @@ -1,160 +1,74 @@ # Execution model -The engine selects a compiled execution module by the scenario's stable `execution.model` name. -Contract v3 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. +The scenario selects one compiled model by `execution.model`. Each model uses strict configuration +version `"1"`, declares its required fields through `--capabilities`, and shares the same order, +risk, fee, settlement, accounting, and audit pipeline. -The completed-bar model consumes synchronized slices of OHLCV bars. Every slice contains exactly -one bar for each configured instrument and produces one matching batch and one closing valuation. +## Available models -## Eligibility +| Model | Market evidence | +| --- | --- | +| `completed_bar_v1` | Next eligible open and optimistic intrabar limit touch | +| `completed_bar_next_open_v1` | Later marketable opens with explicit spread and impact | +| `completed_bar_adverse_touch_v1` | Opens or one-tick adverse trade-through with costs | +| `quote_trade_v1` | Displayed quotes and aggressor-classified trades | +| `order_book_v1` | Bounded level-two snapshots and contiguous updates | -An order records the slice after which it is eligible. The matcher requires: +Completed bars remain mandatory for synchronized valuation. Conservative models require fixed +half-spread and linear participation-impact policies. Quote/trade and order-book models use causal +availability, receipt, and ingest ordering and never infer hidden liquidity. -```text -eligible_after_slice_sequence < current slice_sequence -created_at <= current slice start_at -``` - -This prevents an order emitted from a completed slice from filling inside that slice or at an open -that predates the order. The parser also requires scheduled order-changing intents to arrive by -the next slice start. - -## Portfolio targets - -`target_weights` and `target_quantities` contain one target for every configured instrument. A -weight request: - -1. Values the current account at all synchronized slice closes. -2. Multiplies that equity by each exact weight. -3. Divides by the corresponding closing price. -4. Rounds down to the instrument lot. - -Weights and quantities are signed. Gross absolute weight must stay within `max_leverage`, quantity -targets must align with their lots, and every desired quantity must stay within the configured long -or short position limit. A target that changes sign is reached causally: flatten first, then open -the opposite side on a later attempt. +## Eligibility and order lifetime -The computed desired quantities persist. After each slice, the engine compares them with actual -positions and submits at most one market order per instrument. Each order is capped at -`max_order_quantity` and rounded down to a lot, so large targets advance in bounded chunks. A -market remainder is IOC, but the desired target is retried after a later slice until reached or -superseded. - -## Market and limit prices - -A market order executes at the open of its first eligible slice. - -For a buy limit `L`: - -1. If `open <= L`, fill at `open`. -2. Otherwise, if `low <= L`, fill at `L`. -3. Otherwise, do not fill. - -Sell limits use the symmetric open/high rule. Limit remainders remain GTC. The open rule gives -deterministic gap improvement. The touch rule is optimistic because completed bars contain no -queue, path, or available-size evidence at the limit. - -## Capacity and priority - -Missing volume means unlimited simulated capacity. Otherwise: +An order becomes eligible only when both conditions hold: ```text -raw capacity = floor(volume × participation_bps / 10,000) -capacity = raw capacity rounded down to the instrument lot size +eligible_after_slice_sequence < current slice_sequence +created_at <= current slice start_at ``` -Eligible liquidation orders are ordered before all other orders across the slice. Within the -liquidation and ordinary origin classes, sells precede buys; orders within a side then use -ascending creation sequence and order ID. Each instrument has its own shared capacity, so the -higher-priority order consumes that instrument's capacity first. - -## Callback boundaries - -The matcher fixes the eligible-order sequence at the slice boundary and advances it with an -immutable cursor. After each fill, the engine pauses matching and applies the strategy response -before examining the next order. The cursor then reads that order from the current OMS, skips it -if an earlier response made it terminal, and preserves any capacity that was not consumed. Orders -submitted by a callback are not part of the cursor and remain ineligible until a later slice. - -## Corporate actions and borrow +Market orders attempt the next eligible evidence and cancel any IOC remainder. Limit orders use +their configured time in force. Stop orders activate when their trigger is observed. FOK requires +the full quantity to pass liquidity and risk checks before any fill is applied. -Corporate actions are ordered by action ID and applied before matching. A split scales the signed -position, persistent quantity target, and each active order by its exact numerator/denominator -ratio. It inversely scales limit prices and preserves total position basis. If the adjusted order -cannot satisfy the configured lot or tick, the slice fails instead of silently rounding. Each -changed order emits `order_adjusted` with causal links to both the original order and split. -Unit-based risk limits do not scale with a split. Adjusted positions and persistent targets are -grandfathered: fills may reduce an out-of-limit absolute position but may not increase it, and -reconciliation orders remain bounded by the configured maximum order quantity. An adjusted active -order may exceed that maximum, but no individual fill may do so. +Persistent portfolio targets are reconciled in lot-aligned, maximum-order-sized attempts until the +target is reached or replaced. A sign change flattens before opening the opposite side. -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. -After actions, each open short accrues a quote-currency borrow fee from the slice open mark and the -exact `start_at`/`end_at` duration using a 365-day basis. Positive fees round upward to one money -micro-unit. +## Capacity, priority, and callbacks -## Risk-limited fills and fees +Completed-bar capacity is volume multiplied by `participation_bps`, rounded down to the instrument +lot. Quote and book models use only displayed or causally consumed liquidity. Liquidation orders +run first; within an origin class sells precede buys, followed by FIFO creation order. -Each proposed fill pays: +After each fill, the reducer applies the strategy callback response before examining the next +eligible order. A cancellation can therefore remove a later same-slice order. Newly submitted +orders wait for another slice. -```text -fixed_fee + ceil(fill_notional × fee_bps / 10,000) -``` +## Risk and fees -Liquidation proposals are processed first, followed by sells and then buys within each origin -class. For each proposal, the engine searches for the largest lot-aligned quantity whose signed -post-fill position is within the long/short cap and whose fill quantity is no greater than the -maximum order quantity. -When absolute exposure increases, the projected account must also satisfy maximum gross exposure, -maximum leverage, and initial margin. Reductions in absolute exposure are permitted without a new -initial-margin test. A clipped proposal emits `margin_limited`; a zero permitted quantity produces -no fill. Only the applied quantity consumes shared slice capacity. +Admission reserves every active order's remaining quantity. Fill-time checks use the actual price +and search for the largest permitted lot-aligned quantity under instrument position/notional, +portfolio gross exposure/leverage, margin, locate, and maximum-order limits. Exposure-reducing +fills remain available. A clipped proposal emits a typed `fill_clipped` reason. -This bounded-fill policy preserves split-adjusted GTC limit orders: an oversized remainder may -fill over multiple slices. Market orders remain IOC, so they fill at most one bounded quantity and -cancel any remainder after their eligible slice. +Each instrument has exactly one fee schedule. Components may be fixed, notional basis points, or +per-unit; use explicit currency, rounding, and maker/taker applicability; and may include schedule +minimums, maximums, or rebates. FX conversion and every adjustment are retained in attribution. -Each partial fill pays its own fixed fee, so fragmentation affects total cost. +## Financing, settlement, and lifecycle -## Exact values +Effective-time observations drive short availability, borrow charges, recalls, and per-currency +credit or debit interest. Settlement instructions use explicit business calendars and lags, with +configured settled or total cash and position availability. -Prices, weights, quantities, FX rates, and money use six decimal places stored in checked `int64` -values. Signed quantities are used for positions and targets; submitted orders and fills retain a -positive quantity plus a side. Scenario strings use the canonical shortest representation: `1`, -`1.25`, `-0.5`, and `0.000001` are valid; `01`, `1.0`, excess precision, and negative zero are not. - -Orders align with lot size. Limit prices and executable OHLC values align with tick size. +Corporate actions run before matching. Splits adjust positions, targets, and working orders; +distributions allocate basis and fractional treatment explicitly. Lifecycle events preserve stable +instrument identity across symbol changes and deterministically cancel or cash out terminal assets. ## Accounting and valuation -For a buy that opens or increases a long with notional `N` and fee `F`: - -```text -cash -= N + F -quantity += fill quantity -cost basis += N + F -``` - -For a sell that reduces a long: - -```text -cash += N - F -removed basis = proportional average cost -realized P&L += N - F - removed basis -``` - -Opening a short credits `N - F` to cash and records its cost basis as the negative net proceeds. -Covering a short debits `N + F`; realized P&L is the removed negative basis minus that cover cost. -One fill may reduce a position to zero but may not cross through zero. Closing a position removes -its exact remaining basis. A partial close uses proportional average basis and leaves the exact -remainder open. - -The account maintains a signed cash ledger for every scenario currency. Each slice supplies a -complete currency-to-base FX vector, with base rate one. Valuation converts native cash, market -value, basis, P&L, and fees into the base reporting currency using the current marks: +The engine uses signed average-cost accounting in native quote currencies and converts every cash, +position, basis, P&L, and fee attribution to the scenario base currency. The core identities are: ```text net market value = sum(base FX × mark × signed quantity) @@ -163,18 +77,5 @@ unrealized P&L = net market value - remaining base cost basis equity = base cash + net market value ``` -Each valuation also emits one deterministic attribution row per marked instrument or retained -account position. A nonzero position requires a mark. A flat retained position does not; when its -mark is omitted, the row uses the canonical mark one because every mark produces zero market value -for zero quantity. Row market value, basis, realized P&L, dividend P&L, execution fees, and borrow -fees is present in both native and base values and sums exactly to the corresponding account totals. -A separate row attributes each currency ledger. Closed instruments retain cumulative realized P&L -and fees with zero quantity and basis. - -The valuation includes initial and maintenance requirements and excesses. After strategy and -target processing, negative maintenance excess triggers one `margin_call`, cancels all active -orders, clears the persistent target, and submits deterministic `margin_liquidation` market orders -in instrument-ID order. Strategies receive the resulting cancellation and liquidation-order -updates before the slice valuation. Each attempt is capped by `max_order_quantity` and lot aligned. -The engine continues on later slices until every position is flat, then emits `margin_restored` -when the maintenance condition is no longer breached. +Valuations include initial and maintenance margin. A maintenance breach cancels working orders, +clears targets, and creates bounded liquidation orders until positions are flat. diff --git a/docs/fuzzing.md b/docs/fuzzing.md new file mode 100644 index 0000000..fd584f4 --- /dev/null +++ b/docs/fuzzing.md @@ -0,0 +1,38 @@ +# Protocol fuzzing + +The deterministic protocol harness exercises every untrusted parsing boundary: + +- batch scenario JSON and bounded JSON Lines streams +- external strategy responses +- RFC 3339 timestamps +- fixed-point decimals +- opaque identifiers +- raw JSON used by canonical journal and transcript fixtures + +The seed corpus includes every file under a committed contract `fixtures/` +directory and every materialized case in `contracts/conformance/cases.json`. +Before mutating inputs, the harness replays that complete corpus and fixed hostile +inputs for malformed UTF-8, 512-level nesting, a token larger than one MiB, +duplicate keys, and truncation. + +Run the bounded campaign used by `make check` and CI: + +```sh +make fuzz-smoke +``` + +Run a longer local campaign by choosing the seed and mutation count: + +```sh +FUZZ_SEED=1401 FUZZ_CASES=1000000 make fuzz +``` + +Each mutation truncates, flips, inserts, deletes, or duplicates bytes. A failure +prints the boundary, source name, input length, hexadecimal prefix, and exception. +Repeat the same command with the reported seed and at least the reported case +index to reproduce it. Keep the seed and minimized regression input when adding a +failure-path test. + +The harness checks parser totality, not acceptance. Invalid input may return any +documented diagnostic, but it must not raise an exception, abort, or bypass the +stream and strategy size limits. diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..de45431 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,108 @@ +# Performance + +The engine treats complexity regressions in validation and pure reduction as correctness risks for +large deterministic replays. Performance tests use contract-sized batches and dense schedules but +do not impose machine-specific time limits in `make check`. + +## Reducer feedback queue + +The reducer stores pending intents and notifications in an immutable two-list queue. Tail insertion +and removal are amortized `O(1)`. Prepending a strategy response is `O(k)` in the response size, +independent of work already pending. Processing `n` queued items is therefore `O(n)` apart from the +domain work performed by each item. + +`test/test_reducer.ml` exercises the maximum 4,096-intent strategy batch. Every intent creates a +notification, and the test completes at the exact 8,193-event feedback limit. On the reference +machine, three warm runs of that focused test had these median wall times: + +| Queue implementation | Median | +|---|---:| +| Pending-list append | 0.11 s | +| Immutable two-list queue | 0.01 s | + +## Dense batch schedules + +Batch validation builds one ordered index that associates every slice sequence with its slice and +successor. For `s` slices and `m` schedule entries, index construction and lookup cost +`O((s + m) log s)`, plus intent validation. The prior complete-list lookup cost `O(s * m)`. + +Run the public-CLI benchmark after building the executable: + +```sh +opam exec -- dune build bin/main.exe +python3 bench/benchmark_batch_schedule.py +``` + +The script generates valid batch scenarios outside the timed section, invokes `--validate-only` +three times per size, and prints medians plus the observed range. These results were measured on +2026-08-21 under Linux/WSL2 on an Intel Core i7-10750H using the default Dune development build: + +| Slices and schedule entries | Complete-list lookup | Ordered index | Speedup | +|---:|---:|---:|---:| +| 5,000 | 0.346 s | 0.216 s | 1.6x | +| 10,000 | 0.911 s | 0.407 s | 2.2x | +| 20,000 | 3.264 s | 0.845 s | 3.9x | + +The exact timings are illustrative rather than service-level targets. The growing baseline ratio +and near-linear indexed results are the relevant regression signal. + +## Replay regression suite + +The replay suite measures complete public-CLI runs, including scenario parsing, deterministic +reduction, and artifact publication. It generates every input before the timed interval and covers +the batch and JSON Lines paths independently. + +```sh +make benchmark +``` + +The default run performs one warmup and records the median of three samples in +`benchmark-results/replay.json`. Generated reports are ignored by Git. The table printed to the +terminal and the JSON report include: + +- Wall-clock seconds measured with the monotonic high-resolution clock. +- Peak resident memory of the direct engine process. On Linux this is `ru_maxrss`, normalized to + KiB; an external strategy's own resident memory is intentionally excluded. +- Audit-event throughput, using the CLI's audit count checked against journal line count. +- Artifact-byte throughput, using the published journal size and, for external cases, the strategy + transcript size. + +The full matrix holds all unlisted dimensions constant while varying the source of likely +regressions: + +| Workload pair | Catalog | Slices | Active orders | Strategy latency | +|---|---:|---:|---:|---:| +| Standard history | 1 | 500 | 0 | none | +| Large catalog | 128 | 100 | 0 | none | +| Dense OMS | 1 | 100 | 256 | none | +| External strategy | 1 | 100 | 0 | 0 ms/event | +| Latent external strategy | 1 | 100 | 0 | 5 ms/event | + +Every workload is run in both batch and stream form. Dense-OMS cases submit persistent buy limits +far below the market after the first slice and verify that exactly 256 orders remain active. The +latency strategy returns no intents and sleeps only before each event response, keeping protocol +initialization and shutdown outside the modeled per-event delay. + +### Baseline and tolerance policy + +`bench/baselines/linux-x86_64.json` stores the initial Linux/WSL2 development-build baseline from +the reference machine described in that file. Wall time allows a 35% increase; peak RSS allows a +25% increase; event and artifact throughput allow a 30% decrease. These deliberately broad +tolerances account for scheduler, filesystem-cache, and allocator noise while the project gathers +measurements across more runners. + +Baseline comparison is advisory by default and therefore cannot make `make benchmark` fail. A +reported regression is a prompt to repeat the run on comparable hardware and profile the affected +dimension. On a controlled, baseline-compatible runner, opt into a failing gate with: + +```sh +python3 bench/benchmark_replay.py --enforce +``` + +Refresh a baseline only after explaining an intentional workload or performance change and +recording the engine version, build profile, machine, warmup count, and repetition count. Do not +replace a baseline solely to clear an advisory regression. + +`make check` runs a one-sample smoke matrix with tiny versions of all four replay routes. It checks +input generation, external-strategy protocol behavior, active-order retention, audit counts, and +artifact publication without comparing timing values. diff --git a/docs/persistra.md b/docs/persistra.md index f5bacd3..88070e9 100644 --- a/docs/persistra.md +++ b/docs/persistra.md @@ -1,90 +1,46 @@ # Persistra integration -Persistra and Trading Engine remain separate projects behind a strict process and file boundary. +Persistra and Trading Engine communicate only through versioned files and processes. -Persistra owns provider data, normalized observations, revisions, point-in-time research, -portfolio construction, manifests, analysis, and visualization. Trading Engine owns causal event -sequencing, target sizing, risk, order and fill state, execution simulation, exact accounting, and -execution audit artifacts. +Persistra owns provider ingestion, normalized point-in-time observations, research, portfolio +construction, and run manifests. Trading Engine owns causal sequencing, risk, orders, simulated +execution, financing, settlement, accounting, and audit journals. The engine never reads +Persistra's internal database. ## Handoff -The JSON scenario carries: +Persistra produces scenario contract v1 batch or stream input and should: -- Required `contract_version` identifying the scenario and journal protocol -- Required producer metadata preserved as JSON but ignored by execution -- Required compiled execution-model selection -- One explicit executable-instrument catalog -- Signed position, exposure, leverage, margin, borrow, participation, and fee policies -- Strictly increasing synchronized market slices with complete FX marks and corporate actions -- Explicit initial cash ledgers for every base or quote currency -- Optional scheduled full-portfolio signed weight or fractional quantity targets -- Optional direct orders, cancellations, and metrics +1. Preserve stable executable-instrument identities and provider provenance. +2. Supply complete tick, lot, currency, calendar, risk, execution, financing, and settlement + configuration. +3. Build ordered synchronized slices with explicit availability and receipt times. +4. Preserve target weights instead of pre-sizing them outside the engine. +5. Validate schemas and run `--validate-only` before accepting a replay. +6. Require v1 in the engine's advertised scenario and journal capabilities. +7. Retain the exact scenario hash and require one terminal `run_completed` record. +8. Reconcile journal cash, positions, exposure, P&L, fees, margin, and causal references. -Persistra should: +External strategies use [strategy protocol v1](../contracts/strategy/v1/README.md). Persistra must +retain the scenario, transcript, journal, executable identity, input hashes, and run manifest as one +bound artifact set. -1. Query normalized raw executable bars through its public store API. -2. Preserve provider-scoped instrument IDs or apply an explicit catalog mapping. -3. Supply tick, lot, currency, availability, and receipt policies. -4. Group one bar per instrument into each synchronized slice. -5. Preserve original portfolio weights in `target_weights` instead of pre-sizing them. -6. Populate `metadata` with dataset, policy, and build provenance. -7. Read `--capabilities` and require support for the scenario, journal, and optional strategy - protocol versions. -8. Validate the scenario through the JSON Schema and `--validate-only`. -9. Run the CLI as a separate process and import its audit journal. -10. Require the same contract version and deterministic run-scoped event-ID derivation on every - journal record. -11. Reject duplicate, unknown, forward, cross-run, or noncanonical causal references. -12. Verify the same scenario SHA-256 and selected execution model in `run_started`, - `run_completed`, and the retained manifest. -13. Reconcile every native/base position row and currency cash row to the aggregate account, - exposure, fee, and margin values. -14. Reconcile split adjustments, dividends, borrow fees, risk-limited fills, and margin - liquidation against scenario and runtime state. -15. For external replay, require an empty schedule, launch an explicit strategy argument vector, - hash every declared strategy input, and validate the complete bidirectional transcript. -16. Reconcile external transcript intents to their journal outcomes. -17. Require the terminal completion record before accepting a replay. +## Compatibility gate -Do not let the engine read Persistra's internal DuckDB tables. Their schema and connection -lifecycle belong to Persistra. +Compatibility means the v1 wire contracts pass against an explicit pair of repository commits. +The required `persistra-compatibility` CI job pins the complete Persistra revision in +`.github/workflows/ci.yml`; Persistra owns the reciprocal Trading Engine pin. Neither repository +silently follows a moving branch for its required gate. -Use the current v3 [scenario](../contracts/v3/scenario.schema.json) and -[journal](../contracts/v3/journal.schema.json) JSON Schemas and their adjacent conformance fixtures -for structural checks. The engine parser is authoritative for ordering, catalog coverage, -causality, tick, lot, risk, and accounting invariants that JSON Schema cannot express. +Advance a pin only after both exact checkouts pass their native and cross-repository suites. When +the shared boundary changes, update schemas, fixtures, documentation, and pins together. The +optional moving-head canary is informational and does not change the supported baseline. -External strategies use the separate -[strategy protocol v3](../contracts/strategy/v3/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, -scenario hash, and journal hash. Strategy standard output remains protocol-only; logs and -diagnostics use standard error. +## Time and data rules -Persistra must answer each callback before the engine continues matching. Every callback for a -slice uses the slice receipt time and complete bar and FX snapshot. A later callback therefore -includes accepted intents returned from an earlier callback at that same replay clock. +Use raw prices for execution. Adjusted data may feed research features, but splits and +distributions must be explicit engine events. Provider as-of and Persistra retrieval times remain +provenance; `available_at` and `received_at` define replay causality. -## Time mapping - -- Intraday UTC timestamps map to slice event times. -- Daily labels require an explicit venue-calendar delivery policy. -- Provider as-of time remains source provenance. -- Persistra retrieval time remains acquisition provenance, not replay availability. -- `available_at` states when a strategy may use the complete synchronized slice. -- `received_at` states when the engine run observes it. -- A scheduled order-changing intent must arrive no later than the next slice start. - -Use raw prices for execution. Adjusted values can feed features, but splits and dividends require -explicit engine events before adjusted histories can support share-and-cash accounting. - -## Larger artifacts - -JSON is suitable for small and moderate scenarios. Use the versioned JSON Lines scenario stream -for larger histories. It carries one static header, one slice with its causally adjacent intents -per record, and a required terminal count. The engine validates and replays it with bounded input -and audit memory. Persistra should retain and hash that immutable stream beside the journal and -run manifest. The stream remains a file boundary; it does not couple the engine to Persistra's -database tables. +Use the JSON Lines scenario stream for larger histories. It remains an immutable file boundary, +not a database coupling. diff --git a/docs/reducer-property-testing.md b/docs/reducer-property-testing.md new file mode 100644 index 0000000..4301ecf --- /dev/null +++ b/docs/reducer-property-testing.md @@ -0,0 +1,50 @@ +# Reducer property testing + +The reducer property suite builds valid, shrinkable multi-asset traces and runs them through the +same public transitions used by normal replay. A trace contains four to fourteen synchronized +slices with independent prices, volumes, and EUR/USD marks. Generated strategy commands include +direct market and limit orders, cancellations selected from the live working-order inventory, +quantity and weight targets, and metrics. Matching turns eligible orders into partial or complete +fills. Traces may also include cash dividends and one split, and vary participation, leverage, +initial margin, and maintenance margin. + +Direct-order generation consults the current strategy context. It avoids overlapping working +orders for the same instrument and bounds position-reducing orders so one fill cannot cross through +zero. Invalid business requests may still be generated intentionally: the reducer must express +those as deterministic rejection events rather than corrupting state or escaping the transition. + +## Checked properties + +After every completed slice, `test/test_reducer_properties.ml` checks: + +- cash, equity, net value, long and short value, gross exposure, cost basis, realized and + unrealized P&L, dividends, and fees against their per-currency and per-position attributions; +- native and base-currency conversions, including the defined per-field rounding boundaries; +- initial and maintenance requirements, excess, and margin-call state against the configured risk + model; +- order quantities, fill totals, statuses, active inventory, fill notionals, and fill ownership; +- contiguous engine sequences, derived event IDs, canonical causal references, and the rule that + every cause names an earlier event. + +A second property drives every generated trace through both `Engine.Make` and +`Engine.Interactive`. Audit records are compared as exact serialized bytes after each slice and at +completion; account and order snapshots are compared independently. + +## Reproducing failures + +QCheck prints its random seed and shrinks a failure by removing slices and commands and reducing +numeric inputs. The final report includes the smallest scenario-like JSON trace it found. Re-run a +seed through the complete test gate with: + +```sh +QCHECK_SEED=123456 make test +``` + +Increase the generated case count without changing the checked-in defaults with: + +```sh +QCHECK_SEED=123456 REDUCER_PROPERTY_CASES=5000 make test +``` + +The seed reproduces generation; the printed shrunk trace is the durable debugging artifact when +generator behavior later changes. diff --git a/docs/release-artifacts.md b/docs/release-artifacts.md new file mode 100644 index 0000000..ebbb338 --- /dev/null +++ b/docs/release-artifacts.md @@ -0,0 +1,66 @@ +# Release artifacts and provenance + +This repository can build and verify a release candidate, but it does not automatically choose a +version, create or push a tag, publish an opam package, or create a GitHub release. Each of those +actions requires separate human approval. + +## Artifact set + +An approved version produces these distributable subjects: + +| Artifact | Intended contents | +| --- | --- | +| `trading-engine-VERSION-linux-x86_64.tar.gz` | Installed CLI, OCaml library, package metadata, versioned contracts, fixtures, and license documents | +| `trading-engine-VERSION-source.tar.gz` | Every Git-tracked source file at the exact revision | +| `trading-engine-VERSION-contracts.tar.gz` | Conformance data plus every versioned schema, fixture, and contract README | +| `trading-engine-VERSION-documentation.tar.gz` | Offline strict site with project guides, contract pages and assets, and generated OCaml API pages | +| `trading-engine-VERSION.opam` | Exact checked-in opam package definition | + +The candidate also contains `release-manifest.json`, `SUBJECTS.sha256`, `SHA256SUMS`, an SPDX 2.3 +SBOM, and a deterministic in-toto statement with a SLSA v1 provenance predicate. The SBOM covers +the project and every locked OCaml and Python dependency used to build the artifact set. The +provenance binds subject hashes to the Git revision, lockfile hashes, target, version, and source +date epoch. + +The Linux archive is the supported prebuilt target. Other systems install through the source and +opam artifacts until an equally strict native target is added and independently reproduced. + +## Deterministic build + +Run `make release-check` from a clean tracked revision. The check derives `SOURCE_DATE_EPOCH` from +that commit, performs two clean builds, normalizes archive ownership and timestamps, suppresses +gzip timestamps, and compares the complete candidate directories byte for byte. It then validates +archive topology and metadata, every checksum, SPDX structure, provenance subjects, exact opam +bytes and lint result, and the installed CLI's reported version. + +Generated files are written to ignored `release/`. A candidate is disposable evidence; it is not a +release. The `Release candidate` workflow repeats the check on a fresh Ubuntu runner for relevant +pull requests and `develop` changes and retains the candidate for 14 days. + +## Approval, signing, and publication + +The following is a human-controlled release procedure, not an automated promise: + +1. Approve a version change separately, update public version references, and pass all repository + and cross-repository checks. +2. Review the exact release commit, create an approved signed `vVERSION` tag, and push that tag. +3. Configure required reviewers on the `release` GitHub environment. Manually dispatch the + `Release candidate` workflow from the exact tag and enter the matching version. The workflow + rejects branches and mismatched versions. +4. Approve the environment deployment. GitHub OIDC then obtains short-lived Sigstore certificates + and records signed build-provenance and SPDX SBOM attestations for the subjects. No long-lived + signing key is stored in the repository. +5. Download the candidate and attestation bundles. Verify `sha256sum -c SHA256SUMS`, then verify + each distributable with `gh attestation verify ARTIFACT --repo fallblu/trading-engine`. +6. If an additional offline signature is required, a human signer reviews the hashes and runs + `cosign sign-blob --yes --bundle SHA256SUMS.sigstore.json SHA256SUMS`; a second person verifies + the bundle before publication. +7. Create a draft GitHub release with the existing signed tag and the complete candidate set. + Review downloaded assets and attestations again, then explicitly publish the draft. Never + regenerate or replace artifacts under an existing version. +8. Submit the exact opam file and source checksum to `opam-repository` in a separate reviewed pull + request. Documentation remains versioned inside the release artifact even though the latest + project documentation also lives on GitHub Pages. + +The manual workflow uploads only short-lived Actions artifacts and attestations. It does not create +a tag, GitHub release, release commit, package publication, or version bump. diff --git a/docs/repository-governance.md b/docs/repository-governance.md new file mode 100644 index 0000000..1f39577 --- /dev/null +++ b/docs/repository-governance.md @@ -0,0 +1,25 @@ +# Repository governance + +GitHub settings enforce the integration workflow for the release branch. The reviewed source of +truth lives in `.github/branch-protection.json` and `.github/repository.json`. + +## Main branch protection + +Every change to `main` requires a pull request whose head is current with `main`. The pull request +must resolve all review conversations and pass both checks produced by the mainline workflow: + +- `check`, which runs the complete locked repository gate; +- `persistra-compatibility`, which validates the paired cross-repository baseline. + +The protection blocks force pushes and branch deletion, requires linear history, and applies to +administrators without a bypass. Trading Engine currently has one maintainer, so the rule requires +zero approving reviews. Requiring approval would make self-authored changes impossible to land. +Stale-approval dismissal, code-owner review, and last-push approval are disabled. Revisit that +choice when a second regular reviewer is available. + +## Merge behavior + +Rebase merging is the only supported GitHub merge mode. Merge commits and squash merging are +disabled so coherent commits remain individually visible on a linear history. GitHub deletes +merged head branches automatically. These repository-wide merge settings apply to feature pull +requests into `develop` and promotion pull requests into `main`. diff --git a/docs/scenario.md b/docs/scenario.md index 13706de..951e6fb 100644 --- a/docs/scenario.md +++ b/docs/scenario.md @@ -1,197 +1,73 @@ -# Scenario contract +# Scenario and journal -A replay scenario uses either one strict JSON object or a strict JSON Lines stream. Exact prices, -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 v3 demo](../contracts/v3/fixtures/demo.scenario.json) as the canonical complete example. -The [scenario JSON Schema](../contracts/v3/scenario.schema.json) provides structural validation. -The engine parser also enforces cross-field and cross-record invariants. +Replay input is either one strict JSON scenario or an equivalent JSON Lines stream. The +[v1 schema](../contracts/v1/scenario.schema.json) and +[canonical fixture](../contracts/v1/fixtures/demo.scenario.json) define the batch form. ```sh trading-engine --input scenario.json --validate-only trading-engine --input scenario.jsonl --input-format jsonl --validate-only ``` -## JSON Lines stream - -Use the stream for histories that should not be materialized inside the engine. The first record -is `scenario_header` and carries the static top-level fields. Each `market_slice` record carries -one complete slice and the intents evaluated after that slice. The final `scenario_end` record -declares the number of slices. It is required even for an empty stream, so a truncated valid -prefix cannot be mistaken for a complete scenario. - -Every record has exactly `contract_version`, `scenario_sequence`, `record_type`, and `payload`. -The contract version is repeated, and `scenario_sequence` is contiguous from one. Intents are -adjacent to their decision slice rather than stored in a future-looking global schedule. Before -replay, the reader checks each intent-bearing slice against the next slice's start time while -retaining only those two records. - -The [stream record JSON Schema](../contracts/v3/scenario-stream.schema.json) validates each line, -and [the v3 stream fixture](../contracts/v3/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. - -## Top-level fields - -| Field | Meaning | -|---|---| -| `contract_version` | Required string identifying this file contract; v3 is `"3"` | -| `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 | -| `initial_cash` | One explicit nonnegative balance for every scenario currency | -| `instruments` | Approved executable-instrument catalog | -| `risk` | Signed position, exposure, leverage, margin, and borrow policy | -| `execution` | Capacity and fee configuration | -| `max_internal_events` | Positive reducer feedback cap, at most `4611686018427387903` | -| `schedule` | Intents emitted after named slices | -| `slices` | Complete synchronized market observations | - -Metadata may contain nested JSON values. Duplicate object keys and non-finite numbers are rejected -at any depth. Metadata is retained on `Scenario.t` but never affects execution. - -An external strategy replay requires `schedule: []`. The JSON Lines form likewise requires every -slice record's `intents` array to be empty. This keeps one authoritative decision source: either -the scenario contract or the separate strategy protocol, never both. - -## Instruments, risk, and execution - -Each instrument contains `instrument_id`, `symbol`, `quote_currency`, `tick_size`, and `lot_size`. -Identifiers and labels are nonempty and contain no whitespace or control characters. Tick and lot -sizes are positive exact values with at most six decimal places. Quote currencies may differ from -`base_currency`; `initial_cash` contains every distinct quote currency plus the base currency -exactly once. - -Risk contains positive `max_order_quantity`, `max_long_position`, `max_short_position`, -`max_gross_exposure`, and `max_leverage` values, initial and maintenance margin basis points, and -annualized `short_borrow_bps`. Initial margin cannot be below maintenance margin, and each -quantity limit must cover at least one lot for every instrument. Orders that increase gross -exposure must satisfy every applicable limit; exposure-reducing orders remain admissible. - -Execution contains: - -- `model`, the compiled execution module selected by contract name; v3 supports - `completed_bar_v1` -- `participation_bps`, from 0 through 10,000 -- `fixed_fee`, a nonnegative money string -- `fee_bps`, from 0 through 10,000 - -## Schedule and intents - -Schedule entries are positive, strictly increasing, and anchored to existing slices: - -```json -{ - "after_slice_sequence": "1", - "intents": [ - { - "type": "target_weights", - "targets": [ - { "instrument_id": "asset-a", "weight": "0.6" }, - { "instrument_id": "asset-b", "weight": "0.3" } - ] - } - ] -} -``` +## Scenario structure -Supported intents are: - -- `target_weights` with a `targets` array of `instrument_id` and `weight` -- `target_quantities` with a `targets` array of `instrument_id` and `quantity` -- `submit_order` with instrument, side, quantity, kind, and nullable limit price -- `cancel_order` with a deterministic `order_id` -- `emit_metric` with string `name` and `value` - -Both target forms contain every configured instrument exactly once. Weights and quantities are -signed. Gross absolute weight must not exceed `max_leverage`; quantity targets align to their -instrument lots and stay within the long and short position limits. A rebalance that crosses from -long to short, or short to long, first flattens the existing position and continues toward the -target on a later attempt. - -A market submission uses `"order_kind": "market"` and `"limit_price": null`. A limit submission -uses `"order_kind": "limit"` and a canonical positive price. Static order size, lot, and tick -checks run during parsing; position and outstanding-order checks run in the reducer. - -## Market slices - -Each slice has common timing, one bar per configured instrument, a complete set of currency-to-base -FX marks, and zero or more corporate actions: - -Timestamps use `YYYY-MM-DD[Tt]HH:MM:SS`, optional one-to-six fractional-second digits, and either -`Z`/`z` or a colonized numeric offset such as `-05:00`. Seconds range from `00` through `59`. -Audit timestamps use the same boundary. - -```json -{ - "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": "asset-a", - "open": "100", - "high": "105", - "low": "99", - "close": "104", - "volume": "100" - } - ], - "fx_rates": [ - { "currency": "USD", "rate": "1" } - ], - "corporate_actions": [] -} -``` +Every scenario carries `"contract_version": "1"` and these top-level fields: + +| Field | Purpose | +| --- | --- | +| `metadata` | Producer and dataset provenance; ignored by execution | +| `run_id` | Stable namespace for generated identities | +| `base_currency` | Aggregate reporting currency | +| `initial_portfolio` | Cash, positions, marks, FX, basis, P&L, and fee state | +| `instruments` | Executable catalog with tick, lot, and quote currency | +| `venue_calendars` | Explicit sessions covering every instrument | +| `risk` | Instrument policies, groups, gross exposure, leverage, and borrow limits | +| `execution` | Compiled model and strict v1 model configuration | +| `financing` | Borrow and cash-rate policies | +| `settlement` | Calendars, lags, and cash/position availability | +| `max_internal_events` | Reducer feedback limit | +| `schedule` | Intents emitted after specified slices | +| `slices` | Synchronized market and lifecycle observations | + +Decimals are canonical strings with at most six fractional digits. Counts and basis points are +JSON integers. Unknown fields, duplicate keys, non-finite values, missing catalog coverage, +misaligned ticks or lots, invalid timestamps, and noncausal schedules are rejected. + +Each slice has explicit start, end, availability, and receipt instants; complete bars and FX marks; +and optional corporate actions, financing observations, settlement failures, lifecycle events, +quotes, trades, and order-book events. Slices are strictly ordered and cannot overlap. + +## Stream form + +The [stream schema](../contracts/v1/scenario-stream.schema.json) defines three record types: + +1. `scenario_header` contains the static configuration. +2. Each `market_slice` contains one slice and its causally adjacent intents. +3. `scenario_end` declares the final slice count. + +Every record repeats contract v1 and has a contiguous sequence. The engine validates the complete +stream before creating a journal, then replays it without retaining prior slices or audit events. +Standard input is spooled to a bounded private file so the exact bytes can be validated, hashed, +replayed, and verified again. + +## Strategy intents + +Scheduled and external strategies may submit full-catalog target weights or quantities, direct +orders, cancellations, and typed metrics. An intent produced after slice `n` cannot execute inside +that slice. External strategy scenarios use an empty schedule so there is only one decision source. + +## Journal + +The [journal schema](../contracts/v1/journal.schema.json) defines each append-only record. Every +record carries contract v1, a deterministic event ID, ordered prior causation IDs, the run ID, +recording time, event type, and strict payload. + +A journal begins with `run_started`, `initial_state`, and an initial valuation. It then records the +normalized slices and all strategy, risk, order, execution, financing, settlement, lifecycle, +accounting, and valuation outcomes. Each completed slice emits one closing valuation. Successful +runs end with one `run_completed` record containing the scenario hash, final valuation, and order +counts. -Use `null` volume when unavailable; it means unlimited simulation capacity, not zero. Sequences -are positive and strictly increasing. Slices do not overlap: each start is at or after the prior -end, so equal boundaries are valid. Receipt time never moves backward. Start precedes end, -availability does not precede end, and receipt does not precede availability. OHLC values satisfy -their usual range relationships. Volume may be fractional but must align to the instrument lot. -Each slice supplies exactly one positive FX rate for every scenario currency, and the -base-currency rate is exactly one. - -Supported corporate actions are exact-ratio `split` and per-unit `cash_dividend` records. 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 -grandfathered under the existing reduce-only position policy. Split-adjusted orders remain active, -but each fill is bounded by `max_order_quantity`; GTC limit remainders may fill on later slices, -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. - -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/v3/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 -so a journal remains self-describing when it is streamed or split. - -The first record is `run_started` with `scenario_sha256` and the selected execution model. The CLI -hashes the exact batch document or stream bytes it parses. -`market_slice_received` contains the complete normalized slice. Portfolio requests record their -basis, original weight when applicable, computed quantity, and sizing reference price. Orders use -`eligible_after_slice_sequence`; fills use `slice_sequence`. `margin_limited` records a proposed -fill and the greatest lot-aligned quantity permitted by maximum order quantity, position, -exposure, leverage, and initial margin policy. Each order snapshot retains both creation and -latest-update event IDs. - -The journal also records split/dividend application, split-driven order adjustments, short borrow -fees, margin calls, liquidation-origin orders, and restoration. Every valuation contains complete -per-currency cash attribution, signed per-instrument native and base-currency attribution, long, -short, net, and gross exposure, execution and borrow fees, and its initial/maintenance margin -snapshot. Those rows reconcile exactly to the aggregate valuation. - -A successful replay ends with exactly one `run_completed` record containing the same scenario -hash, reconciled valuation, and mutually exclusive order-status counts. A journal without that -terminal record is incomplete. The requested journal path appears only after exclusive successful -finalization; a failed run retains the `.partial` artifact. +The requested path is published only after the terminal record is closed successfully. Failures +retain the `.partial` artifact. `--durable-artifacts` additionally synchronizes file and directory +metadata on supported filesystems. diff --git a/docs/security-maintenance.md b/docs/security-maintenance.md new file mode 100644 index 0000000..7d9a491 --- /dev/null +++ b/docs/security-maintenance.md @@ -0,0 +1,63 @@ +# Security maintenance + +Trading Engine combines GitHub security settings, bounded dependency proposals, static analysis, +and repository verification. These controls identify changes for a maintainer to assess. They do +not replace the complete gate, threat analysis, or the human-controlled release process. + +Report an undisclosed vulnerability through the private channel in the +[security policy](https://github.com/fallblu/trading-engine/security/policy). + +## Dependency updates + +Dependabot checks GitHub Actions and the Python documentation and schema tools every Monday. Patch +and minor updates are grouped by ecosystem. Major updates remain separate so their compatibility +impact is visible. At most five version-update pull requests per ecosystem remain open at once. + +Version-update pull requests target `develop`. GitHub always targets Dependabot security-update +pull requests at the repository's default branch, which is `main`; `target-branch` cannot change +that behavior. Treat such a pull request as a security warning and hotfix input. Do not merge it +directly as an ordinary feature change. Reproduce the dependency and lockfile change through the +documented hotfix or `develop` integration flow, then use the human-controlled release process. + +Review each Python input and generated lockfile together. Run the complete repository gate after a +change to `requirements/docs.in`, `requirements/docs.lock`, `requirements/schema.in`, or +`requirements/schema.lock`. + +Dependabot does not support opam manifests. Update `trading_engine.opam` and +`trading_engine.opam.locked` together through a reviewed pull request. The exact locked gate and +the required lowest and highest dependency-band cells must pass. The bands resolve the declared +opam bounds independently, so they catch compatibility errors that one lock cannot. + +Dependabot configuration lives on `develop` until the next human release carries it to `main`, +where GitHub reads `.github/dependabot.yml`. Repository vulnerability alerts and automatic +security-fix proposals are enabled independently through GitHub security settings. + +## Static and dependency analysis + +CodeQL analyzes GitHub Actions workflows and Python build tooling on pull requests and pushes to +`develop` and `main`, on a weekly schedule, and when started manually. Both languages use the +`security-extended` query suite and `none` build mode. The workflow checks out source without +credentials and does not build or execute repository code. + +CodeQL does not provide an OCaml extractor. The CodeQL check therefore makes no static-analysis +claim about the reducer, protocol parsers, external process supervisor, or artifact writer. OCaml +assurance comes from compiler warnings, formatting, deterministic tests, schema conformance, +property tests, fixed fuzz corpora, coverage, and dependency-band checks. These are verification +controls, not a substitute for OCaml security review. + +Dependency review runs on pull requests to `develop` and `main`. It rejects newly introduced +dependencies represented in GitHub's dependency graph when they have vulnerabilities of moderate +severity or higher. It does not execute pull-request code. GitHub does not natively resolve the +opam lock for this check, so the opam review and CI gates remain required. + +## Findings and suppressions + +Investigate each CodeQL or dependency-review finding against the affected path and supported +dependency range. Prefer a code fix, dependency update, or constraint change. Record the evidence +and affected versions in the pull request or a linked issue. + +Do not add broad query exclusions or advisory allowlists. A narrow suppression requires maintainer +review, a linked tracking issue, a reason such as confirmed false positive or unreachable test +code, and a condition for removal. Use GitHub's finding dismissal controls for CodeQL so the reason +and reviewer remain auditable. Any future dependency-review advisory exception must name one GHSA +and follow the same review rules. Re-run the affected workflow after a fix or suppression change. diff --git a/dune b/dune new file mode 100644 index 0000000..6ac61e8 --- /dev/null +++ b/dune @@ -0,0 +1 @@ +(dirs :standard \ site) diff --git a/dune-project b/dune-project index de35c8d..eb0b69d 100644 --- a/dune-project +++ b/dune-project @@ -1,7 +1,7 @@ (lang dune 3.24) (name trading_engine) -(version 1.0.0) +(version 1.1.0) (generate_opam_files false) (implicit_transitive_deps false) diff --git a/lib/account.ml b/lib/account.ml index 9b18274..ecfffbf 100644 --- a/lib/account.ml +++ b/lib/account.ml @@ -7,19 +7,46 @@ type position = { dividend_pnl : Scalar.Money.t; execution_fees : Scalar.Money.t; borrow_fees : Scalar.Money.t; + execution_fee_components : execution_fee_component list; +} + +and execution_fee_component = { + name : string; + kind : string; + currency : string; + amount : Scalar.Money.t; + quote_amount : Scalar.Money.t; +} + +type execution_fee_component_attribution = { + name : string; + kind : string; + currency : string; + amount : Scalar.Money.t; + quote_currency : string; + quote_amount : Scalar.Money.t; + base_amount : Scalar.Money.t; } type cash_attribution = { currency : string; amount : Scalar.Money.t; + settled_amount : Scalar.Money.t; + unsettled_amount : Scalar.Money.t; fx_rate : Scalar.Price.t; base_value : Scalar.Money.t; + base_settled_value : Scalar.Money.t; + base_unsettled_value : Scalar.Money.t; + interest : Scalar.Money.t; + base_interest : Scalar.Money.t; } type position_attribution = { instrument_id : Id.Instrument.t; quote_currency : string; quantity : Scalar.Quantity.t; + settled_quantity : Scalar.Quantity.t; + unsettled_quantity : Scalar.Quantity.t; mark : Scalar.Price.t; fx_rate : Scalar.Price.t; market_value : Scalar.Money.t; @@ -38,18 +65,24 @@ type position_attribution = { base_borrow_fees : Scalar.Money.t; total_fees : Scalar.Money.t; base_total_fees : Scalar.Money.t; + execution_fee_components : execution_fee_component_attribution list; } type t = { base_currency : string; initial_cash : Scalar.Money.t Currency_map.t; cash : Scalar.Money.t Currency_map.t; + settled_cash : Scalar.Money.t Currency_map.t; + cash_interest : Scalar.Money.t Currency_map.t; positions : position Id.Instrument.Map.t; + settled_positions : Scalar.Quantity.t Id.Instrument.Map.t; } type valuation = { base_currency : string; cash : Scalar.Money.t; + settled_cash : Scalar.Money.t; + unsettled_cash : Scalar.Money.t; net_market_value : Scalar.Money.t; long_market_value : Scalar.Money.t; short_market_value : Scalar.Money.t; @@ -61,9 +94,11 @@ type valuation = { dividend_pnl : Scalar.Money.t; execution_fees : Scalar.Money.t; borrow_fees : Scalar.Money.t; + cash_interest : Scalar.Money.t; total_fees : Scalar.Money.t; cash_balances : cash_attribution list; positions : position_attribution list; + execution_fee_components : execution_fee_component_attribution list; } let ( let* ) result function_ = @@ -77,6 +112,7 @@ let empty_position = dividend_pnl = Scalar.Money.zero; execution_fees = Scalar.Money.zero; borrow_fees = Scalar.Money.zero; + execution_fee_components = []; } let valid_currency value = @@ -112,14 +148,57 @@ let create ~base_currency ~initial_cash = base_currency; initial_cash = balances; cash = balances; + settled_cash = balances; + cash_interest = Currency_map.map (fun _ -> Scalar.Money.zero) balances; positions = Id.Instrument.Map.empty; + settled_positions = Id.Instrument.Map.empty; } +let of_initial_portfolio (initial : Initial_portfolio.t) = + let cash = + List.fold_left + (fun balances (currency, amount) -> + Currency_map.add currency amount balances) + Currency_map.empty initial.cash + in + let positions = + List.fold_left + (fun positions (value : Initial_portfolio.position) -> + Id.Instrument.Map.add value.instrument_id + { + quantity = value.quantity; + cost_basis = value.cost_basis; + realized_pnl = value.realized_pnl; + dividend_pnl = value.dividend_pnl; + execution_fees = value.execution_fees; + borrow_fees = value.borrow_fees; + execution_fee_components = []; + } + positions) + Id.Instrument.Map.empty initial.positions + in + Ok + { + base_currency = initial.base_currency; + initial_cash = cash; + cash; + settled_cash = cash; + cash_interest = Currency_map.map (fun _ -> Scalar.Money.zero) cash; + positions; + settled_positions = + Id.Instrument.Map.map + (fun (value : position) -> value.quantity) + positions; + } + let base_currency (state : t) = state.base_currency let initial_cash (state : t) = Currency_map.bindings state.initial_cash let cash_balances (state : t) = Currency_map.bindings state.cash let cash (state : t) currency = Currency_map.find_opt currency state.cash +let settled_cash (state : t) currency = + Currency_map.find_opt currency state.settled_cash + let position (state : t) instrument_id = Option.value (Id.Instrument.Map.find_opt instrument_id state.positions) @@ -128,6 +207,11 @@ let position (state : t) instrument_id = let position_quantity (state : t) instrument_id = (position state instrument_id).quantity +let settled_position_quantity (state : t) instrument_id = + Option.value + (Id.Instrument.Map.find_opt instrument_id state.settled_positions) + ~default:Scalar.Quantity.zero + let positions (state : t) = Id.Instrument.Map.bindings state.positions let total_fees (position : position) = @@ -142,6 +226,7 @@ let update_position (positions : position Id.Instrument.Map.t) instrument_id && Scalar.Money.equal value.dividend_pnl Scalar.Money.zero && Scalar.Money.equal value.execution_fees Scalar.Money.zero && Scalar.Money.equal value.borrow_fees Scalar.Money.zero + && value.execution_fee_components = [] then Id.Instrument.Map.remove instrument_id positions else Id.Instrument.Map.add instrument_id value positions @@ -152,9 +237,67 @@ let adjust_cash (state : t) currency delta = let* amount = Scalar.Money.add current delta in Ok { state with cash = Currency_map.add currency amount state.cash } -let add_execution_fee (position : position) fee = +let adjust_settled_cash (state : t) currency delta = + match Currency_map.find_opt currency state.settled_cash with + | None -> Error ("missing settled cash ledger for currency " ^ currency) + | Some current -> + let* amount = Scalar.Money.add current delta in + Ok + { + state with + settled_cash = Currency_map.add currency amount state.settled_cash; + } + +let adjust_settled_position (state : t) instrument_id delta = + let current = settled_position_quantity state instrument_id in + let* quantity = Scalar.Quantity.add current delta in + let settled_positions = + if Scalar.Quantity.is_zero quantity then + Id.Instrument.Map.remove instrument_id state.settled_positions + else Id.Instrument.Map.add instrument_id quantity state.settled_positions + in + Ok { state with settled_positions } + +let add_fee_component (components : execution_fee_component list) + (component : Fee_schedule.calculated_component) = + let rec add prefix = function + | [] -> + Ok + (List.rev_append prefix + [ + { + name = component.name; + kind = component.kind; + currency = component.currency; + amount = component.amount; + quote_amount = component.quote_amount; + }; + ]) + | (current : execution_fee_component) :: remaining + when String.equal current.name component.name + && String.equal current.kind component.kind + && String.equal current.currency component.currency -> + let* amount = Scalar.Money.add current.amount component.amount in + let* quote_amount = + Scalar.Money.add current.quote_amount component.quote_amount + in + Ok + (List.rev_append prefix + ({ current with amount; quote_amount } :: remaining)) + | current :: remaining -> add (current :: prefix) remaining + in + add [] components + +let add_execution_fee (position : position) fee fee_components = let* execution_fees = Scalar.Money.add position.execution_fees fee in - Ok { position with execution_fees } + let* execution_fee_components = + List.fold_left + (fun result component -> + let* components = result in + add_fee_component components component) + (Ok position.execution_fee_components) fee_components + in + Ok { position with execution_fees; execution_fee_components } let ensure_no_cross current delta = let* projected = Scalar.Quantity.add current delta in @@ -172,7 +315,9 @@ let apply_open_long (state : t) fill (current : position) projected = let* state = adjust_cash state fill.quote_currency cash_delta in let* cost_basis = Scalar.Money.add current.cost_basis acquisition_cost in let* updated = - add_execution_fee { current with quantity = projected; cost_basis } fill.fee + add_execution_fee + { current with quantity = projected; cost_basis } + fill.fee fill.fee_components in Ok { @@ -186,7 +331,9 @@ let apply_open_short (state : t) fill (current : position) projected = let* basis_delta = Scalar.Money.negate net_proceeds in let* cost_basis = Scalar.Money.add current.cost_basis basis_delta in let* updated = - add_execution_fee { current with quantity = projected; cost_basis } fill.fee + add_execution_fee + { current with quantity = projected; cost_basis } + fill.fee fill.fee_components in Ok { @@ -209,7 +356,7 @@ let apply_close_long (state : t) fill (current : position) projected = let* updated = add_execution_fee { current with quantity = projected; cost_basis; realized_pnl } - fill.fee + fill.fee fill.fee_components in Ok { @@ -237,7 +384,7 @@ let apply_close_short (state : t) fill (current : position) projected = let* updated = add_execution_fee { current with quantity = projected; cost_basis; realized_pnl } - fill.fee + fill.fee fill.fee_components in Ok { @@ -245,7 +392,7 @@ let apply_close_short (state : t) fill (current : position) projected = positions = update_position state.positions fill.instrument_id updated; } -let apply_fill (state : t) fill = +let apply_unsettled_fill (state : t) fill = let current = position state fill.Fill.instrument_id in match fill.side with | Order.Buy -> @@ -260,6 +407,30 @@ let apply_fill (state : t) fill = apply_close_long state fill current projected else apply_open_short state fill current projected +let settlement_movements (fill : Fill.t) = + match fill.side with + | Order.Buy -> + let* debit = Scalar.Money.add fill.notional fill.fee in + let* cash = Scalar.Money.negate debit in + Ok (cash, fill.quantity) + | Order.Sell -> + let* cash = Scalar.Money.subtract fill.notional fill.fee in + let* position = Scalar.Quantity.negate fill.quantity in + Ok (cash, position) + +let apply_settlement (state : t) (instruction : Settlement.instruction) = + let* state = + adjust_settled_cash state instruction.currency instruction.cash_movement + in + adjust_settled_position state instruction.instrument_id + instruction.position_movement + +let apply_fill (state : t) fill = + let* state = apply_unsettled_fill state fill in + let* cash_movement, position_movement = settlement_movements fill in + let* state = adjust_settled_cash state fill.quote_currency cash_movement in + adjust_settled_position state fill.instrument_id position_movement + let apply_split (state : t) ~instrument_id ~numerator ~denominator = let current = position state instrument_id in if Scalar.Quantity.is_zero current.quantity then Ok state @@ -270,7 +441,16 @@ let apply_split (state : t) ~instrument_id ~numerator ~denominator = let positions = update_position state.positions instrument_id { current with quantity } in - Ok { state with positions } + let settled = settled_position_quantity state instrument_id in + let* settled = + Scalar.Quantity.scale_ratio_exact settled ~numerator ~denominator + in + let settled_positions = + if Scalar.Quantity.is_zero settled then + Id.Instrument.Map.remove instrument_id state.settled_positions + else Id.Instrument.Map.add instrument_id settled state.settled_positions + in + Ok { state with positions; settled_positions } let apply_cash_dividend (state : t) ~instrument_id ~quote_currency ~amount_per_unit = @@ -279,6 +459,7 @@ let apply_cash_dividend (state : t) ~instrument_id ~quote_currency else let* amount = Scalar.Money.for_quantity amount_per_unit current.quantity in let* state = adjust_cash state quote_currency amount in + let* state = adjust_settled_cash state quote_currency amount in let* realized_pnl = Scalar.Money.add current.realized_pnl amount in let* dividend_pnl = Scalar.Money.add current.dividend_pnl amount in let updated = { current with realized_pnl; dividend_pnl } in @@ -288,15 +469,185 @@ 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 Error "borrow fees require an open short position" - else if Scalar.Money.compare fee Scalar.Money.zero <= 0 then - Error "borrow fee must be positive" + else if Scalar.Money.equal fee Scalar.Money.zero then + Error "borrow fee must be nonzero" else let* cash_delta = Scalar.Money.negate fee in let* state = adjust_cash state quote_currency cash_delta in + let* state = adjust_settled_cash state quote_currency cash_delta in let* realized_pnl = Scalar.Money.add current.realized_pnl cash_delta in let* borrow_fees = Scalar.Money.add current.borrow_fees fee in let updated = { current with realized_pnl; borrow_fees } in @@ -306,6 +657,23 @@ let apply_borrow_fee (state : t) ~instrument_id ~quote_currency ~fee = positions = update_position state.positions instrument_id updated; } +let apply_cash_interest (state : t) ~currency ~interest = + if Scalar.Money.equal interest Scalar.Money.zero then Ok state + else + let* state = adjust_cash state currency interest in + let* state = adjust_settled_cash state currency interest in + let current = + Option.value + (Currency_map.find_opt currency state.cash_interest) + ~default:Scalar.Money.zero + in + let* total = Scalar.Money.add current interest in + Ok + { + state with + cash_interest = Currency_map.add currency total state.cash_interest; + } + let value (state : t) ~instruments ~marks ~fx_rates = let canonical_flat_mark = Scalar.Price.of_micros Scalar.Price.scale |> Result.get_ok @@ -335,7 +703,37 @@ let value (state : t) ~instruments ~marks ~fx_rates = let cash_attribution (currency, amount) = let* fx_rate = fx currency in let* base_value = Scalar.Money.convert amount ~rate:fx_rate in - Ok { currency; amount; fx_rate; base_value } + let settled_amount = + Option.value + (Currency_map.find_opt currency state.settled_cash) + ~default:Scalar.Money.zero + in + let* unsettled_amount = Scalar.Money.subtract amount settled_amount in + let* base_settled_value = + Scalar.Money.convert settled_amount ~rate:fx_rate + in + let* base_unsettled_value = + Scalar.Money.convert unsettled_amount ~rate:fx_rate + in + let interest = + Option.value + (Currency_map.find_opt currency state.cash_interest) + ~default:Scalar.Money.zero + in + let* base_interest = Scalar.Money.convert interest ~rate:fx_rate in + Ok + { + currency; + amount; + settled_amount; + unsettled_amount; + fx_rate; + base_value; + base_settled_value; + base_unsettled_value; + interest; + base_interest; + } in let* cash_balances = Currency_map.bindings state.cash @@ -350,6 +748,10 @@ let value (state : t) ~instruments ~marks ~fx_rates = let position_attribution instrument = let instrument_id = instrument.Instrument.id in let current = position state instrument_id in + let settled_quantity = settled_position_quantity state instrument_id in + let* unsettled_quantity = + Scalar.Quantity.subtract current.quantity settled_quantity + in let* mark = match Id.Instrument.Map.find_opt instrument_id mark_map with | Some value -> Ok value @@ -375,11 +777,35 @@ let value (state : t) ~instruments ~marks ~fx_rates = let* base_execution_fees = convert current.execution_fees in let* base_borrow_fees = convert current.borrow_fees in let* base_total_fees = convert total_fees in + let* execution_fee_components = + List.fold_left + (fun result (component : execution_fee_component) -> + let* values = result in + let* component_fx = fx component.currency in + let* base_amount = + Scalar.Money.convert component.amount ~rate:component_fx + in + Ok + ({ + name = component.name; + kind = component.kind; + currency = component.currency; + amount = component.amount; + quote_currency = instrument.quote_currency; + quote_amount = component.quote_amount; + base_amount; + } + :: values)) + (Ok []) current.execution_fee_components + |> Result.map List.rev + in Ok { instrument_id; quote_currency = instrument.quote_currency; quantity = current.quantity; + settled_quantity; + unsettled_quantity; mark; fx_rate; market_value; @@ -398,6 +824,7 @@ let value (state : t) ~instruments ~marks ~fx_rates = base_borrow_fees; total_fees; base_total_fees; + execution_fee_components; } in let unknown_mark = @@ -466,6 +893,21 @@ let value (state : t) ~instruments ~marks ~fx_rates = add total item.base_value) (Ok Scalar.Money.zero) cash_balances in + let* settled_cash = + List.fold_left + (fun result item -> + let* total = result in + add total item.base_settled_value) + (Ok Scalar.Money.zero) cash_balances + in + let* unsettled_cash = Scalar.Money.subtract cash settled_cash in + let* cash_interest = + List.fold_left + (fun result item -> + let* total = result in + add total item.base_interest) + (Ok Scalar.Money.zero) cash_balances + in let accumulate result item = let* ( net_market_value, long_market_value, @@ -532,12 +974,44 @@ let value (state : t) ~instruments ~marks ~fx_rates = Scalar.Money.zero )) positions in + let* realized_pnl = add realized_pnl cash_interest in let* gross_exposure = add long_market_value short_market_value in let* equity = add cash net_market_value in + let add_attribution (components : execution_fee_component_attribution list) + (component : execution_fee_component_attribution) = + let rec add_component prefix = function + | [] -> Ok (List.rev_append prefix [ component ]) + | (current : execution_fee_component_attribution) :: remaining + when String.equal current.name component.name + && String.equal current.kind component.kind + && String.equal current.currency component.currency + && String.equal current.quote_currency component.quote_currency -> + let* amount = add current.amount component.amount in + let* quote_amount = add current.quote_amount component.quote_amount in + let* base_amount = add current.base_amount component.base_amount in + Ok + (List.rev_append prefix + ({ current with amount; quote_amount; base_amount } :: remaining)) + | current :: remaining -> add_component (current :: prefix) remaining + in + add_component [] components + in + let* execution_fee_components = + List.fold_left + (fun result (position : position_attribution) -> + List.fold_left + (fun result component -> + let* components = result in + add_attribution components component) + result position.execution_fee_components) + (Ok []) positions + in Ok { base_currency = state.base_currency; cash; + settled_cash; + unsettled_cash; net_market_value; long_market_value; short_market_value; @@ -549,9 +1023,11 @@ let value (state : t) ~instruments ~marks ~fx_rates = dividend_pnl; execution_fees; borrow_fees; + cash_interest; total_fees; cash_balances; positions; + execution_fee_components; } let pp_valuation formatter valuation = diff --git a/lib/account.mli b/lib/account.mli index a0845a7..ac4a1f5 100644 --- a/lib/account.mli +++ b/lib/account.mli @@ -1,6 +1,24 @@ (** Exact multi-currency cash, signed-position, cost-basis, and P&L accounting. *) +type execution_fee_component = private { + name : string; + kind : string; + currency : string; + amount : Scalar.Money.t; + quote_amount : Scalar.Money.t; +} + +type execution_fee_component_attribution = private { + name : string; + kind : string; + currency : string; + amount : Scalar.Money.t; + quote_currency : string; + quote_amount : Scalar.Money.t; + base_amount : Scalar.Money.t; +} + type position = private { quantity : Scalar.Quantity.t; cost_basis : Scalar.Money.t; @@ -8,19 +26,28 @@ type position = private { dividend_pnl : Scalar.Money.t; execution_fees : Scalar.Money.t; borrow_fees : Scalar.Money.t; + execution_fee_components : execution_fee_component list; } type cash_attribution = private { currency : string; amount : Scalar.Money.t; + settled_amount : Scalar.Money.t; + unsettled_amount : Scalar.Money.t; fx_rate : Scalar.Price.t; base_value : Scalar.Money.t; + base_settled_value : Scalar.Money.t; + base_unsettled_value : Scalar.Money.t; + interest : Scalar.Money.t; + base_interest : Scalar.Money.t; } type position_attribution = private { instrument_id : Id.Instrument.t; quote_currency : string; quantity : Scalar.Quantity.t; + settled_quantity : Scalar.Quantity.t; + unsettled_quantity : Scalar.Quantity.t; mark : Scalar.Price.t; fx_rate : Scalar.Price.t; market_value : Scalar.Money.t; @@ -39,6 +66,7 @@ type position_attribution = private { base_borrow_fees : Scalar.Money.t; total_fees : Scalar.Money.t; base_total_fees : Scalar.Money.t; + execution_fee_components : execution_fee_component_attribution list; } type t @@ -46,6 +74,8 @@ type t type valuation = private { base_currency : string; cash : Scalar.Money.t; + settled_cash : Scalar.Money.t; + unsettled_cash : Scalar.Money.t; net_market_value : Scalar.Money.t; long_market_value : Scalar.Money.t; short_market_value : Scalar.Money.t; @@ -57,9 +87,11 @@ type valuation = private { dividend_pnl : Scalar.Money.t; execution_fees : Scalar.Money.t; borrow_fees : Scalar.Money.t; + cash_interest : Scalar.Money.t; total_fees : Scalar.Money.t; cash_balances : cash_attribution list; positions : position_attribution list; + execution_fee_components : execution_fee_component_attribution list; } val create : @@ -67,14 +99,19 @@ val create : initial_cash:(string * Scalar.Money.t) list -> (t, string) result +val of_initial_portfolio : Initial_portfolio.t -> (t, string) result val base_currency : t -> string val initial_cash : t -> (string * Scalar.Money.t) list val cash_balances : t -> (string * Scalar.Money.t) list val cash : t -> string -> Scalar.Money.t option +val settled_cash : t -> string -> Scalar.Money.t option val position : t -> Id.Instrument.t -> position val position_quantity : t -> Id.Instrument.t -> Scalar.Quantity.t +val settled_position_quantity : t -> Id.Instrument.t -> Scalar.Quantity.t val positions : t -> (Id.Instrument.t * position) list val apply_fill : t -> Fill.t -> (t, string) result +val apply_unsettled_fill : t -> Fill.t -> (t, string) result +val apply_settlement : t -> Settlement.instruction -> (t, string) result val apply_split : t -> @@ -90,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 -> @@ -97,6 +161,9 @@ val apply_borrow_fee : fee:Scalar.Money.t -> (t, string) result +val apply_cash_interest : + t -> currency:string -> interest:Scalar.Money.t -> (t, string) result + val value : t -> instruments:Instrument.t list -> diff --git a/lib/artifact_writer.ml b/lib/artifact_writer.ml new file mode 100644 index 0000000..efc6994 --- /dev/null +++ b/lib/artifact_writer.ml @@ -0,0 +1,413 @@ +type open_state +type closed_state +type published_state +type renamed_state +type complete_state +type durability = Buffered | Durable + +type 'state file = { + label : string; + final_path : string; + partial_path : string; + cleanup_path : string; + channel : out_channel; + effects : Boundary_effects.t; + durability : durability; +} + +type state = + | Open of open_state file + | Closed of closed_state file + | Published of published_state file + | Renamed of renamed_state file + | Complete of complete_state file + +type t = { mutable state : state } + +let state_label = function + | Open file -> file.label + | Closed file -> file.label + | Published file -> file.label + | Renamed file -> file.label + | Complete file -> file.label + +let diagnostic ~code message = + Diagnostic.make ~code ~phase:Diagnostic.Artifact message + +let exception_message = function + | Sys_error message -> message + | Unix.Unix_error (code, operation, target) -> + Printf.sprintf "%s(%s): %s" operation target (Unix.error_message code) + | exception_ -> Printexc.to_string exception_ + +let exception_diagnostic ~label action exception_ = + Diagnostic.of_exception ~code:Diagnostic.Artifact_io + ~phase:Diagnostic.Artifact + ~message: + (Printf.sprintf "could not %s %s: %s" action label + (exception_message exception_)) + exception_ + +let move_no_replace source_path target_path = + Unix.link source_path target_path; + try Unix.unlink source_path + with exception_ -> + (try Unix.unlink target_path with _ -> ()); + raise exception_ + +let transition file = + { + label = file.label; + final_path = file.final_path; + partial_path = file.partial_path; + cleanup_path = file.cleanup_path; + channel = file.channel; + effects = file.effects; + durability = file.durability; + } + +let create ?(effects = Boundary_effects.direct) ?(durability = Buffered) ~label + final_path = + let partial_path = final_path ^ ".partial" in + let cleanup_path = partial_path ^ ".cleanup" in + if Sys.file_exists final_path then + Error + (diagnostic ~code:Diagnostic.Artifact_exists + (Printf.sprintf "%s already exists: %s" label final_path)) + else if Sys.file_exists partial_path then + Error + (diagnostic ~code:Diagnostic.Artifact_exists + (Printf.sprintf "partial %s already exists: %s" label partial_path)) + else if Sys.file_exists cleanup_path then + Error + (diagnostic ~code:Diagnostic.Artifact_exists + (Printf.sprintf "%s cleanup path already exists: %s" label cleanup_path)) + else + try + let channel = + Boundary_effects.perform effects + (Boundary_effects.Create_artifact partial_path) (fun () -> + open_out_gen + [ Open_wronly; Open_creat; Open_excl; Open_binary ] + 0o600 partial_path) + in + Ok + { + state = + Open + { + label; + final_path; + partial_path; + cleanup_path; + channel; + effects; + durability; + }; + } + with exception_ -> Error (exception_diagnostic ~label "create" exception_) + +let append artifact contents = + match artifact.state with + | Closed _ | Published _ | Renamed _ | Complete _ -> + Error + (diagnostic ~code:Diagnostic.Artifact_state + ("cannot append to a closed " ^ state_label artifact.state)) + | Open file -> ( + if String.length contents > Resource_limits.artifact_record_bytes then + Error + (diagnostic ~code:Diagnostic.Resource_limit + (Printf.sprintf "%s record is %d bytes; limit is %d bytes" + file.label (String.length contents) + Resource_limits.artifact_record_bytes)) + else + try + Boundary_effects.perform file.effects + (Boundary_effects.Write_artifact + { channel = file.channel; contents }) + (fun () -> output_string file.channel contents); + Boundary_effects.perform file.effects Boundary_effects.Flush_artifact + (fun () -> flush file.channel); + Ok () + with exception_ -> + Error (exception_diagnostic ~label:file.label "append" exception_)) + +let close_file artifact file = + let closed = transition file in + try + Boundary_effects.perform file.effects Boundary_effects.Flush_artifact + (fun () -> flush file.channel); + (match file.durability with + | Buffered -> () + | Durable -> + Boundary_effects.perform file.effects + (Boundary_effects.Sync_artifact file.channel) (fun () -> + Unix.fsync (Unix.descr_of_out_channel file.channel))); + Boundary_effects.perform file.effects Boundary_effects.Close_artifact + (fun () -> close_out file.channel); + artifact.state <- Closed closed; + Ok () + with exception_ -> + close_out_noerr file.channel; + artifact.state <- Closed closed; + Error (exception_diagnostic ~label:file.label "close" exception_) + +let close_preserving_partial artifact = + match artifact.state with + | Open file -> + close_out_noerr file.channel; + artifact.state <- Closed (transition file) + | Closed _ | Published _ | Renamed _ | Complete _ -> () + +let publish artifact = + match artifact.state with + | Closed file -> ( + try + Boundary_effects.perform file.effects + (Boundary_effects.Publish_artifact + { partial_path = file.partial_path; final_path = file.final_path }) + (fun () -> Unix.link file.partial_path file.final_path); + artifact.state <- Published (transition file); + Ok () + with exception_ -> + Error (exception_diagnostic ~label:file.label "publish" exception_)) + | Open _ | Published _ | Renamed _ | Complete _ -> + Error + (diagnostic ~code:Diagnostic.Artifact_state + ("cannot publish " ^ state_label artifact.state + ^ " from its current state")) + +let rename_partial artifact = + match artifact.state with + | Published file -> ( + try + Boundary_effects.perform file.effects + (Boundary_effects.Rename_artifact + { + source_path = file.partial_path; + target_path = file.cleanup_path; + }) + (fun () -> move_no_replace file.partial_path file.cleanup_path); + artifact.state <- Renamed (transition file); + Ok () + with exception_ -> + Error (exception_diagnostic ~label:file.label "rename" exception_)) + | Open _ | Closed _ | Renamed _ | Complete _ -> + Error + (diagnostic ~code:Diagnostic.Artifact_state + ("cannot rename " ^ state_label artifact.state + ^ " from its current state")) + +let cleanup_partial artifact = + match artifact.state with + | Renamed file -> ( + try + Boundary_effects.perform file.effects + (Boundary_effects.Cleanup_artifact file.cleanup_path) (fun () -> + Unix.unlink file.cleanup_path); + artifact.state <- Complete (transition file); + Ok () + with exception_ -> + Error (exception_diagnostic ~label:file.label "clean up" exception_)) + | Open _ | Closed _ | Published _ | Complete _ -> + Error + (diagnostic ~code:Diagnostic.Artifact_state + ("cannot clean up " ^ state_label artifact.state + ^ " from its current state")) + +let rollback artifact = + match artifact.state with + | Published file -> ( + try + Boundary_effects.perform file.effects + (Boundary_effects.Cleanup_artifact file.final_path) (fun () -> + Unix.unlink file.final_path); + artifact.state <- Closed (transition file); + Ok () + with exception_ -> + Error (exception_diagnostic ~label:file.label "roll back" exception_)) + | Open _ | Closed _ | Renamed _ | Complete _ -> Ok () + +let restore_partial artifact = + match artifact.state with + | Renamed file -> ( + try + if not (Sys.file_exists file.partial_path) then + Boundary_effects.perform file.effects + (Boundary_effects.Rename_artifact + { + source_path = file.cleanup_path; + target_path = file.partial_path; + }) + (fun () -> move_no_replace file.cleanup_path file.partial_path); + artifact.state <- Published (transition file); + Ok () + with exception_ -> + Error (exception_diagnostic ~label:file.label "restore" exception_)) + | Complete file -> ( + try + if not (Sys.file_exists file.partial_path) then + Boundary_effects.perform file.effects + (Boundary_effects.Restore_artifact + { + final_path = file.final_path; + partial_path = file.partial_path; + }) + (fun () -> Unix.link file.final_path file.partial_path); + artifact.state <- Published (transition file); + Ok () + with exception_ -> + Error (exception_diagnostic ~label:file.label "restore" exception_)) + | Open _ | Closed _ | Published _ -> Ok () + +let combine original = function + | Ok () -> original + | Error cleanup -> Diagnostic.combine original cleanup + +let rollback_all artifacts original = + List.fold_left + (fun diagnostic artifact -> rollback artifact |> combine diagnostic) + original artifacts + +let restore_all artifacts original = + List.fold_left + (fun diagnostic artifact -> restore_partial artifact |> combine diagnostic) + original artifacts + +let directory_effects artifacts = + let add directories artifact = + let add_file file = + match file.durability with + | Buffered -> directories + | Durable -> + let directory = Filename.dirname file.final_path in + if + List.exists + (fun (path, _) -> String.equal path directory) + directories + then directories + else (directory, file.effects) :: directories + in + match artifact.state with + | Open file -> add_file file + | Closed file -> add_file file + | Published file -> add_file file + | Renamed file -> add_file file + | Complete file -> add_file file + in + List.fold_left add [] artifacts |> List.rev + +let sync_directory (directory, effects) = + try + Boundary_effects.perform effects (Boundary_effects.Sync_directory directory) + (fun () -> + let descriptor = Unix.openfile directory [ Unix.O_RDONLY ] 0 in + Fun.protect + ~finally:(fun () -> Unix.close descriptor) + (fun () -> Unix.fsync descriptor)); + Ok () + with exception_ -> + Error + (exception_diagnostic + ~label:("artifact directory " ^ directory) + "synchronize" exception_) + +let rec sync_directories = function + | [] -> Ok () + | directory :: rest -> ( + match sync_directory directory with + | Ok () -> sync_directories rest + | Error _ as error -> error) + +let add_cleanup diagnostic = function + | Ok () -> diagnostic + | Error cleanup -> Diagnostic.combine diagnostic cleanup + +let rollback_after_failure artifacts directories diagnostic = + let diagnostic = rollback_all artifacts diagnostic in + sync_directories directories |> add_cleanup diagnostic + +let restore_after_failure artifacts directories diagnostic = + let diagnostic = restore_all artifacts diagnostic in + sync_directories directories |> add_cleanup diagnostic + +let rec close_all = function + | [] -> Ok () + | artifact :: rest -> ( + match artifact.state with + | Open file -> ( + match close_file artifact file with + | Ok () -> close_all rest + | Error diagnostic -> + List.iter close_preserving_partial rest; + Error diagnostic) + | Closed _ | Published _ | Renamed _ | Complete _ -> + List.iter close_preserving_partial rest; + Error + (diagnostic ~code:Diagnostic.Artifact_state + (state_label artifact.state ^ " is not open for commit"))) + +let rec publish_all published = function + | [] -> Ok () + | artifact :: rest -> ( + match publish artifact with + | Ok () -> publish_all (artifact :: published) rest + | Error diagnostic -> Error (rollback_all published diagnostic)) + +let cleanup_all artifacts = + let rec loop = function + | [] -> Ok () + | artifact :: rest -> ( + match cleanup_partial artifact with + | Ok () -> loop rest + | Error _ as error -> error) + in + loop artifacts + +let rename_all artifacts = + let rec loop = function + | [] -> Ok () + | artifact :: rest -> ( + match rename_partial artifact with + | Ok () -> loop rest + | Error _ as error -> error) + in + loop artifacts + +let commit artifacts = + match artifacts with + | [] -> + Error + (diagnostic ~code:Diagnostic.Artifact_state + "artifact commit requires at least one writer") + | _ -> ( + let directories = directory_effects artifacts in + match close_all artifacts with + | Error _ as error -> error + | Ok () -> ( + match publish_all [] artifacts with + | Error diagnostic -> + Error (rollback_after_failure artifacts directories diagnostic) + | Ok () -> ( + match sync_directories directories with + | Error diagnostic -> + Error + (rollback_after_failure artifacts directories diagnostic) + | Ok () -> ( + match rename_all artifacts with + | Error diagnostic -> + Error + (restore_after_failure artifacts directories diagnostic) + | Ok () -> ( + match cleanup_all artifacts with + | Error diagnostic -> + Error + (restore_after_failure artifacts directories + diagnostic) + | Ok () -> ( + match sync_directories directories with + | Ok () -> Ok () + | Error diagnostic -> + Error + (restore_after_failure artifacts directories + diagnostic))))))) diff --git a/lib/artifact_writer.mli b/lib/artifact_writer.mli new file mode 100644 index 0000000..ae74cc6 --- /dev/null +++ b/lib/artifact_writer.mli @@ -0,0 +1,17 @@ +(** Exclusive staged-file writer with a typed internal lifecycle. *) + +type t +type durability = Buffered | Durable + +val create : + ?effects:Boundary_effects.t -> + ?durability:durability -> + label:string -> + string -> + (t, Diagnostic.t) result + +val append : t -> string -> (unit, Diagnostic.t) result +val close_preserving_partial : t -> unit + +val commit : t list -> (unit, Diagnostic.t) result +(** Close, publish, and clean up every staged writer as one operation. *) diff --git a/lib/audit.ml b/lib/audit.ml index eeb48ec..36a578d 100644 --- a/lib/audit.ml +++ b/lib/audit.ml @@ -2,7 +2,14 @@ type cancellation_reason = | Strategy_requested | Target_replaced | Market_ioc + | Immediate_or_cancel + | Fill_or_kill + | Day_expired + | Gtd_expired | Margin_call + | Borrow_recall + | Instrument_halt + | Instrument_terminal type target_basis = Weights | Quantities @@ -25,6 +32,7 @@ type valuation = { account : Account.valuation; margin : Risk.margin_snapshot } type event = | Run_started of { scenario_sha256 : string; execution_model : string } + | Initial_state of { portfolio : Initial_portfolio.t; valuation : valuation } | Market_slice_received of Market_slice.t | Target_portfolio_requested of { basis : target_basis; @@ -32,6 +40,7 @@ type event = } | Order_accepted of Order.t | Order_rejected of Order.t + | Order_triggered of Order.t | Order_cancelled of { order : Order.t; reason : cancellation_reason } | Split_applied of { action : Corporate_action.t; @@ -43,29 +52,66 @@ 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 } + | Execution_price_selected of { + order_id : Id.Order.t; + instrument_id : Id.Instrument.t; + side : Order.side; + attribution : Execution.price_attribution; + } | Fill_applied of Fill.t - | Margin_limited of { + | Settlement_instruction_created of Settlement.instruction + | Settlement_completed of Settlement.instruction + | Settlement_failed of Settlement.instruction + | Fill_clipped of { order_id : Id.Order.t; instrument_id : Id.Instrument.t; - requested_quantity : Scalar.Quantity.t; + proposed_quantity : Scalar.Quantity.t; permitted_quantity : Scalar.Quantity.t; price : Scalar.Price.t; + limit : Risk.fill_limit; } - | Borrow_fee_applied of { - instrument_id : Id.Instrument.t; + | Borrow_charge_applied of { + observation : Financing.borrow_observation; quote_currency : string; short_quantity : Scalar.Quantity.t; reference_price : Scalar.Price.t; - borrow_bps : int; + day_count : Financing.day_count; + compounding : Financing.compounding; + period_start : Ptime.t; + period_end : Ptime.t; + amount : Scalar.Money.t; + } + | Borrow_recall_received of { + observation : Financing.borrow_observation; + short_quantity : Scalar.Quantity.t; + close_out_quantity : Scalar.Quantity.t; + } + | Cash_interest_applied of { + observation : Financing.cash_rate_observation; + opening_balance : Scalar.Money.t; + applied_rate_bps : int; + day_count : Financing.day_count; + compounding : Financing.compounding; period_start : Ptime.t; period_end : Ptime.t; - fee : Scalar.Money.t; + amount : Scalar.Money.t; + closing_balance : Scalar.Money.t; } | Margin_call_triggered of valuation | Margin_restored of valuation | Intent_rejected of string - | Metric_emitted of { name : string; value : string } + | Metric_emitted of Metric.t | Valuation of valuation | Run_completed of { scenario_sha256 : string; @@ -88,9 +134,10 @@ let event_id ~run_id ~engine_sequence = Printf.sprintf "%s-event-%012Ld" (Id.Run.to_string run_id) engine_sequence |> Id.Event.of_string_exn -let create ~engine_sequence ~causation_ids ~run_id ~recorded_at event = +let create ~contract_version ~engine_sequence ~causation_ids ~run_id + ~recorded_at event = { - contract_version = Contract.version; + contract_version; engine_sequence; event_id = event_id ~run_id ~engine_sequence; causation_ids; @@ -103,7 +150,14 @@ let cancellation_reason_to_string = function | Strategy_requested -> "strategy_requested" | Target_replaced -> "target_replaced" | Market_ioc -> "market_ioc" + | Immediate_or_cancel -> "immediate_or_cancel" + | Fill_or_kill -> "fill_or_kill" + | Day_expired -> "day_expired" + | 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" @@ -111,17 +165,27 @@ let target_basis_to_string = function let event_name = function | Run_started _ -> "run_started" + | Initial_state _ -> "initial_state" | Market_slice_received _ -> "market_slice_received" | Target_portfolio_requested _ -> "target_portfolio_requested" | Order_accepted _ -> "order_accepted" | Order_rejected _ -> "order_rejected" + | Order_triggered _ -> "order_triggered" | 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" + | Execution_price_selected _ -> "execution_price_selected" | Fill_applied _ -> "fill_applied" - | Margin_limited _ -> "margin_limited" - | Borrow_fee_applied _ -> "borrow_fee_applied" + | Settlement_instruction_created _ -> "settlement_instruction_created" + | Settlement_completed _ -> "settlement_completed" + | Settlement_failed _ -> "settlement_failed" + | Fill_clipped _ -> "fill_clipped" + | Borrow_charge_applied _ -> "borrow_charge_applied" + | Borrow_recall_received _ -> "borrow_recall_received" + | Cash_interest_applied _ -> "cash_interest_applied" | Margin_call_triggered _ -> "margin_call" | Margin_restored _ -> "margin_restored" | Intent_rejected _ -> "intent_rejected" diff --git a/lib/audit.mli b/lib/audit.mli index a8b10ab..20ba914 100644 --- a/lib/audit.mli +++ b/lib/audit.mli @@ -4,7 +4,14 @@ type cancellation_reason = | Strategy_requested | Target_replaced | Market_ioc + | Immediate_or_cancel + | Fill_or_kill + | Day_expired + | Gtd_expired | Margin_call + | Borrow_recall + | Instrument_halt + | Instrument_terminal type target_basis = Weights | Quantities @@ -27,6 +34,7 @@ type valuation = { account : Account.valuation; margin : Risk.margin_snapshot } type event = | Run_started of { scenario_sha256 : string; execution_model : string } + | Initial_state of { portfolio : Initial_portfolio.t; valuation : valuation } | Market_slice_received of Market_slice.t | Target_portfolio_requested of { basis : target_basis; @@ -34,6 +42,7 @@ type event = } | Order_accepted of Order.t | Order_rejected of Order.t + | Order_triggered of Order.t | Order_cancelled of { order : Order.t; reason : cancellation_reason } | Split_applied of { action : Corporate_action.t; @@ -45,29 +54,66 @@ 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 } + | Execution_price_selected of { + order_id : Id.Order.t; + instrument_id : Id.Instrument.t; + side : Order.side; + attribution : Execution.price_attribution; + } | Fill_applied of Fill.t - | Margin_limited of { + | Settlement_instruction_created of Settlement.instruction + | Settlement_completed of Settlement.instruction + | Settlement_failed of Settlement.instruction + | Fill_clipped of { order_id : Id.Order.t; instrument_id : Id.Instrument.t; - requested_quantity : Scalar.Quantity.t; + proposed_quantity : Scalar.Quantity.t; permitted_quantity : Scalar.Quantity.t; price : Scalar.Price.t; + limit : Risk.fill_limit; } - | Borrow_fee_applied of { - instrument_id : Id.Instrument.t; + | Borrow_charge_applied of { + observation : Financing.borrow_observation; quote_currency : string; short_quantity : Scalar.Quantity.t; reference_price : Scalar.Price.t; - borrow_bps : int; + day_count : Financing.day_count; + compounding : Financing.compounding; + period_start : Ptime.t; + period_end : Ptime.t; + amount : Scalar.Money.t; + } + | Borrow_recall_received of { + observation : Financing.borrow_observation; + short_quantity : Scalar.Quantity.t; + close_out_quantity : Scalar.Quantity.t; + } + | Cash_interest_applied of { + observation : Financing.cash_rate_observation; + opening_balance : Scalar.Money.t; + applied_rate_bps : int; + day_count : Financing.day_count; + compounding : Financing.compounding; period_start : Ptime.t; period_end : Ptime.t; - fee : Scalar.Money.t; + amount : Scalar.Money.t; + closing_balance : Scalar.Money.t; } | Margin_call_triggered of valuation | Margin_restored of valuation | Intent_rejected of string - | Metric_emitted of { name : string; value : string } + | Metric_emitted of Metric.t | Valuation of valuation | Run_completed of { scenario_sha256 : string; @@ -89,6 +135,7 @@ type t = private { val event_id : run_id:Id.Run.t -> engine_sequence:int64 -> Id.Event.t val create : + contract_version:string -> engine_sequence:int64 -> causation_ids:Id.Event.t list -> run_id:Id.Run.t -> diff --git a/lib/boundary_effects.ml b/lib/boundary_effects.ml new file mode 100644 index 0000000..9e8d983 --- /dev/null +++ b/lib/boundary_effects.ml @@ -0,0 +1,70 @@ +type stage = + | Artifact_create + | Artifact_write + | Artifact_flush + | Artifact_sync_file + | Artifact_close + | Artifact_publish + | Artifact_rename + | Artifact_cleanup + | Artifact_restore + | Artifact_sync_directory + | Process_spawn + | Process_exchange + | Process_terminate + | Process_reap + +type operation = + | Create_artifact of string + | Write_artifact of { channel : out_channel; contents : string } + | Flush_artifact + | Sync_artifact of out_channel + | Close_artifact + | Publish_artifact of { partial_path : string; final_path : string } + | Rename_artifact of { source_path : string; target_path : string } + | Cleanup_artifact of string + | Restore_artifact of { final_path : string; partial_path : string } + | Sync_directory of string + | Spawn_process + | Exchange_process + | Terminate_process + | Reap_process + +type t = { perform : 'a. operation -> (unit -> 'a) -> 'a } + +let direct = { perform = (fun _ run -> run ()) } + +let perform (type result) effects operation (run : unit -> result) = + effects.perform operation run + +let stage = function + | Create_artifact _ -> Artifact_create + | Write_artifact _ -> Artifact_write + | Flush_artifact -> Artifact_flush + | Sync_artifact _ -> Artifact_sync_file + | Close_artifact -> Artifact_close + | Publish_artifact _ -> Artifact_publish + | Rename_artifact _ -> Artifact_rename + | Cleanup_artifact _ -> Artifact_cleanup + | Restore_artifact _ -> Artifact_restore + | Sync_directory _ -> Artifact_sync_directory + | Spawn_process -> Process_spawn + | Exchange_process -> Process_exchange + | Terminate_process -> Process_terminate + | Reap_process -> Process_reap + +let stage_to_string = function + | Artifact_create -> "artifact create" + | Artifact_write -> "artifact write" + | Artifact_flush -> "artifact flush" + | Artifact_sync_file -> "artifact file sync" + | Artifact_close -> "artifact close" + | Artifact_publish -> "artifact publish" + | Artifact_rename -> "artifact rename" + | Artifact_cleanup -> "artifact cleanup" + | Artifact_restore -> "artifact restore" + | Artifact_sync_directory -> "artifact directory sync" + | Process_spawn -> "process spawn" + | Process_exchange -> "process exchange" + | Process_terminate -> "process terminate" + | Process_reap -> "process reap" diff --git a/lib/boundary_effects.mli b/lib/boundary_effects.mli new file mode 100644 index 0000000..f9dc0cb --- /dev/null +++ b/lib/boundary_effects.mli @@ -0,0 +1,40 @@ +(** Minimal effect dispatcher for deterministic boundary failure injection. *) + +type stage = + | Artifact_create + | Artifact_write + | Artifact_flush + | Artifact_sync_file + | Artifact_close + | Artifact_publish + | Artifact_rename + | Artifact_cleanup + | Artifact_restore + | Artifact_sync_directory + | Process_spawn + | Process_exchange + | Process_terminate + | Process_reap + +type operation = + | Create_artifact of string + | Write_artifact of { channel : out_channel; contents : string } + | Flush_artifact + | Sync_artifact of out_channel + | Close_artifact + | Publish_artifact of { partial_path : string; final_path : string } + | Rename_artifact of { source_path : string; target_path : string } + | Cleanup_artifact of string + | Restore_artifact of { final_path : string; partial_path : string } + | Sync_directory of string + | Spawn_process + | Exchange_process + | Terminate_process + | Reap_process + +type t = { perform : 'a. operation -> (unit -> 'a) -> 'a } + +val direct : t +val perform : t -> operation -> (unit -> 'a) -> 'a +val stage : operation -> stage +val stage_to_string : stage -> string diff --git a/lib/codec.ml b/lib/codec.ml index 2525ce8..c399627 100644 --- a/lib/codec.ml +++ b/lib/codec.ml @@ -82,6 +82,130 @@ let instrument_id value = string (Id.Instrument.to_string value) let order_id value = string (Id.Order.to_string value) let fill_id value = string (Id.Fill.to_string value) +let fill_limit_to_yojson = function + | Risk.Maximum_order_quantity value -> + ( "max_order_quantity", + `Assoc [ ("unit", string "quantity"); ("value", quantity value) ] ) + | Risk.Maximum_long_position value -> + ( "max_long_position", + `Assoc [ ("unit", string "quantity"); ("value", quantity value) ] ) + | Risk.Maximum_short_position value -> + ( "max_short_position", + `Assoc [ ("unit", string "quantity"); ("value", quantity value) ] ) + | Risk.Maximum_gross_exposure value -> + ( "max_gross_exposure", + `Assoc [ ("unit", string "money"); ("value", money value) ] ) + | Risk.Maximum_leverage value -> + ( "max_leverage", + `Assoc + [ + ("unit", string "ratio"); + ("value", string (Scalar.Ratio.to_decimal_string value)); + ] ) + | Risk.Initial_margin value -> + ( "initial_margin", + `Assoc [ ("unit", string "basis_points"); ("value", `Int value) ] ) + | Risk.Instrument_maximum_long_position (id, value) -> + ( "instrument_max_long_position", + `Assoc + [ + ("instrument_id", instrument_id id); + ("unit", string "quantity"); + ("value", quantity value); + ] ) + | Risk.Instrument_maximum_short_position (id, value) -> + ( "instrument_max_short_position", + `Assoc + [ + ("instrument_id", instrument_id id); + ("unit", string "quantity"); + ("value", quantity value); + ] ) + | Risk.Instrument_maximum_notional (id, value) -> + ( "instrument_max_notional_exposure", + `Assoc + [ + ("instrument_id", instrument_id id); + ("unit", string "money"); + ("value", money value); + ] ) + | Risk.Instrument_shorting_disabled id -> + ( "instrument_shorting_disabled", + `Assoc [ ("instrument_id", instrument_id id); ("value", `Bool false) ] + ) + | Risk.Instrument_borrow_availability (id, value) -> + ( "instrument_borrow_availability", + `Assoc + [ + ("instrument_id", instrument_id id); + ("unit", string "quantity"); + ("value", quantity value); + ] ) + | Risk.Settlement_cash_buying_power (currency, value) -> + ( "settlement_cash_buying_power", + `Assoc + [ + ("currency", string currency); + ("unit", string "money"); + ("value", money value); + ] ) + | Risk.Settlement_position_availability (id, value) -> + ( "settlement_position_availability", + `Assoc + [ + ("instrument_id", instrument_id id); + ("unit", string "quantity"); + ("value", quantity value); + ] ) + | Risk.Instrument_initial_margin (id, value) -> + ( "instrument_initial_margin", + `Assoc + [ + ("instrument_id", instrument_id id); + ("unit", string "basis_points"); + ("value", `Int value); + ] ) + | Risk.Group_maximum_gross (id, value) -> + ( "group_max_gross_exposure", + `Assoc + [ + ("group_id", string (Id.Risk_group.to_string id)); + ("unit", string "money"); + ("value", money value); + ] ) + | Risk.Group_maximum_long (id, value) -> + ( "group_max_long_exposure", + `Assoc + [ + ("group_id", string (Id.Risk_group.to_string id)); + ("unit", string "money"); + ("value", money value); + ] ) + | Risk.Group_maximum_short (id, value) -> + ( "group_max_short_exposure", + `Assoc + [ + ("group_id", string (Id.Risk_group.to_string id)); + ("unit", string "money"); + ("value", money value); + ] ) + | Risk.Group_maximum_absolute_net (id, value) -> + ( "group_max_absolute_net_exposure", + `Assoc + [ + ("group_id", string (Id.Risk_group.to_string id)); + ("unit", string "money"); + ("value", money value); + ] ) + | Risk.Group_maximum_concentration (id, value) -> + ( "group_max_concentration", + `Assoc + [ + ("group_id", string (Id.Risk_group.to_string id)); + ("unit", string "ratio"); + ("value", string (Scalar.Ratio.to_decimal_string value)); + ] ) + let bar_to_yojson bar = `Assoc [ @@ -117,6 +241,204 @@ 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 + [ + ("instrument_id", instrument_id observation.Financing.instrument_id); + ("effective_at", timestamp observation.effective_at); + ("available_quantity", quantity observation.available_quantity); + ("annual_rate_bps", `Int observation.annual_rate_bps); + ("recalled", `Bool observation.recalled); + ] + +let cash_rate_observation_to_yojson observation = + `Assoc + [ + ("currency", string observation.Financing.currency); + ("effective_at", timestamp observation.effective_at); + ("credit_rate_bps", `Int observation.credit_rate_bps); + ("debit_rate_bps", `Int observation.debit_rate_bps); + ] + +let settlement_failure_to_yojson failure = + `Assoc + [ + ("instruction_id", string failure.Settlement.instruction_id); + ("reason", string failure.reason); + ] + +let market_event_to_yojson event = + let common = + [ + ("instrument_id", instrument_id event.Market_event.instrument_id); + ("event_at", timestamp event.event_at); + ("available_at", timestamp event.available_at); + ("received_at", timestamp event.received_at); + ("ingest_sequence", int64 event.ingest_sequence); + ] + in + match event.kind with + | Market_event.Quote { bid_price; bid_quantity; ask_price; ask_quantity } -> + `Assoc + ((("type", string "quote") :: common) + @ [ + ("bid_price", price bid_price); + ("bid_quantity", quantity bid_quantity); + ("ask_price", price ask_price); + ("ask_quantity", quantity ask_quantity); + ]) + | Market_event.Trade { price = value; quantity = size; aggressor_side } -> + `Assoc + ((("type", string "trade") :: common) + @ [ + ("price", price value); + ("quantity", quantity size); + ( "aggressor_side", + string (Market_event.aggressor_side_to_string aggressor_side) ); + ]) + +let order_book_level_to_yojson level = + `Assoc + [ + ("price", price level.Order_book_event.price); + ("quantity", quantity level.quantity); + ] + +let order_book_event_to_yojson event = + let common = + [ + ("instrument_id", instrument_id event.Order_book_event.instrument_id); + ("event_at", timestamp event.event_at); + ("available_at", timestamp event.available_at); + ("received_at", timestamp event.received_at); + ("ingest_sequence", int64 event.ingest_sequence); + ("book_sequence", int64 event.book_sequence); + ] + in + match event.kind with + | Order_book_event.Snapshot { bids; asks } -> + `Assoc + ((("type", string "snapshot") :: common) + @ [ + ("bids", `List (List.map order_book_level_to_yojson bids)); + ("asks", `List (List.map order_book_level_to_yojson asks)); + ]) + | Set { side; price = value; quantity = size } -> + `Assoc + ((("type", string "set") :: common) + @ [ + ("side", string (Order_book_event.side_to_string side)); + ("price", price value); + ("quantity", quantity size); + ]) + | Delete { side; price = value } -> + `Assoc + ((("type", string "delete") :: common) + @ [ + ("side", string (Order_book_event.side_to_string side)); + ("price", price value); + ]) + | Trade { price = value; quantity = size; aggressor_side } -> + `Assoc + ((("type", string "trade") :: common) + @ [ + ("price", price value); + ("quantity", quantity size); + ( "aggressor_side", + string (Market_event.aggressor_side_to_string aggressor_side) ); + ]) let market_slice_to_yojson market_slice = `Assoc @@ -132,20 +454,64 @@ let market_slice_to_yojson market_slice = `List (List.map corporate_action_to_yojson market_slice.corporate_actions) ); + ( "borrow_observations", + `List + (List.map borrow_observation_to_yojson + market_slice.Market_slice.borrow_observations) ); + ( "cash_rate_observations", + `List + (List.map cash_rate_observation_to_yojson + market_slice.Market_slice.cash_rate_observations) ); + ( "settlement_failures", + `List + (List.map settlement_failure_to_yojson + market_slice.Market_slice.settlement_failures) ); + ( "lifecycle_events", + `List + (List.map lifecycle_event_to_yojson + market_slice.Market_slice.lifecycle_events) ); + ( "market_events", + `List + (List.map market_event_to_yojson + market_slice.Market_slice.market_events) ); + ( "order_book_events", + `List + (List.map order_book_event_to_yojson + market_slice.Market_slice.order_book_events) ); ] let request_fields request = - let kind, limit_price = + let kind, trigger_price, limit_price = match request.Order.kind with - | Order.Market -> ("market", `Null) - | Order.Limit value -> ("limit", price value) + | Order.Market -> ("market", `Null, `Null) + | Order.Limit value -> ("limit", `Null, price value) + | Order.Stop value -> ("stop", price value, `Null) + | Order.Stop_limit { trigger_price; limit_price } -> + ("stop_limit", price trigger_price, price limit_price) + in + let tif, venue_id, calendar_id, expires_at = + match request.time_in_force with + | Order.Gtc -> ("gtc", `Null, `Null, `Null) + | Order.Ioc -> ("ioc", `Null, `Null, `Null) + | Order.Fok -> ("fok", `Null, `Null, `Null) + | Order.Day { venue_id; calendar_id } -> + ( "day", + string (Id.Venue.to_string venue_id), + string (Id.Venue_calendar.to_string calendar_id), + `Null ) + | Order.Gtd value -> ("gtd", `Null, `Null, timestamp value) in [ ("instrument_id", instrument_id request.instrument_id); ("side", string (Order.side_to_string request.side)); ("quantity", quantity request.quantity); ("order_kind", string kind); + ("trigger_price", trigger_price); ("limit_price", limit_price); + ("time_in_force", string tif); + ("venue_id", venue_id); + ("calendar_id", calendar_id); + ("expires_at", expires_at); ("origin", string (Order.origin_to_string request.origin)); ] @@ -155,6 +521,12 @@ let order_to_yojson order = | Order.Rejected reason -> string reason | _ -> `Null in + let triggered_at, triggered_slice_sequence = + match order.trigger_state with + | Some (Order.Triggered { triggered_at; triggered_slice_sequence }) -> + (timestamp triggered_at, int64 triggered_slice_sequence) + | Some Order.Dormant | None -> (`Null, `Null) + in `Assoc ((("order_id", order_id order.id) :: request_fields order.request) @ [ @@ -164,12 +536,24 @@ let order_to_yojson order = ("created_at", timestamp order.created_at); ( "eligible_after_slice_sequence", int64 order.eligible_after_slice_sequence ); + ("triggered_at", triggered_at); + ("triggered_slice_sequence", triggered_slice_sequence); ("filled_quantity", quantity order.filled_quantity); ("filled_notional", money order.filled_notional); ("status", string (Order.status_to_string order.status)); ("rejection_reason", rejection_reason); ]) +let calculated_fee_component_to_yojson component = + `Assoc + [ + ("name", string component.Fee_schedule.name); + ("kind", string component.kind); + ("currency", string component.currency); + ("amount", money component.amount); + ("quote_amount", money component.quote_amount); + ] + let fill_to_yojson fill = `Assoc [ @@ -182,16 +566,73 @@ let fill_to_yojson fill = ("price", price fill.price); ("notional", money fill.notional); ("fee", money fill.fee); + ( "fee_components", + `List (List.map calculated_fee_component_to_yojson fill.fee_components) + ); ("executed_at", timestamp fill.executed_at); ("slice_sequence", int64 fill.slice_sequence); ] +let initial_position_to_yojson (position : Initial_portfolio.position) = + `Assoc + [ + ("instrument_id", instrument_id position.instrument_id); + ("quantity", quantity position.quantity); + ("cost_basis", money position.cost_basis); + ("realized_pnl", money position.realized_pnl); + ("dividend_pnl", money position.dividend_pnl); + ("execution_fees", money position.execution_fees); + ("borrow_fees", money position.borrow_fees); + ] + +let initial_portfolio_to_yojson (portfolio : Initial_portfolio.t) = + let cash = + List.map + (fun (currency, amount) -> + `Assoc [ ("currency", string currency); ("amount", money amount) ]) + portfolio.cash + in + let marks = + List.map + (fun (id, value) -> + `Assoc [ ("instrument_id", instrument_id id); ("price", price value) ]) + portfolio.marks + in + let fx_rates = + List.map + (fun (currency, rate) -> + `Assoc [ ("currency", string currency); ("rate", price rate) ]) + portfolio.fx_rates + in + `Assoc + [ + ("cash", `List cash); + ( "positions", + `List (List.map initial_position_to_yojson portfolio.positions) ); + ("marks", `List marks); + ("fx_rates", `List fx_rates); + ] + +let execution_fee_component_attribution_to_yojson component = + `Assoc + [ + ("name", string component.Account.name); + ("kind", string component.kind); + ("currency", string component.currency); + ("amount", money component.amount); + ("quote_currency", string component.quote_currency); + ("quote_amount", money component.quote_amount); + ("base_amount", money component.base_amount); + ] + let position_attribution_to_yojson position = `Assoc [ ("instrument_id", instrument_id position.Account.instrument_id); ("quote_currency", string position.quote_currency); ("quantity", quantity position.quantity); + ("settled_quantity", quantity position.settled_quantity); + ("unsettled_quantity", quantity position.unsettled_quantity); ("mark", price position.mark); ("fx_rate", price position.fx_rate); ("market_value", money position.market_value); @@ -206,6 +647,10 @@ let position_attribution_to_yojson position = ("base_dividend_pnl", money position.base_dividend_pnl); ("execution_fees", money position.execution_fees); ("base_execution_fees", money position.base_execution_fees); + ( "execution_fee_components", + `List + (List.map execution_fee_component_attribution_to_yojson + position.execution_fee_components) ); ("borrow_fees", money position.borrow_fees); ("base_borrow_fees", money position.base_borrow_fees); ("total_fees", money position.total_fees); @@ -217,8 +662,14 @@ let cash_attribution_to_yojson cash = [ ("currency", string cash.Account.currency); ("amount", money cash.amount); + ("settled_amount", money cash.settled_amount); + ("unsettled_amount", money cash.unsettled_amount); ("fx_rate", price cash.fx_rate); ("base_value", money cash.base_value); + ("base_settled_value", money cash.base_settled_value); + ("base_unsettled_value", money cash.base_unsettled_value); + ("interest", money cash.interest); + ("base_interest", money cash.base_interest); ] let account_valuation_to_yojson valuation = @@ -236,8 +687,15 @@ let account_valuation_to_yojson valuation = ("equity", money valuation.equity); ("dividend_pnl", money valuation.dividend_pnl); ("execution_fees", money valuation.execution_fees); + ( "execution_fee_components", + `List + (List.map execution_fee_component_attribution_to_yojson + valuation.Account.execution_fee_components) ); ("borrow_fees", money valuation.borrow_fees); ("total_fees", money valuation.total_fees); + ("cash_interest", money valuation.cash_interest); + ("settled_cash", money valuation.settled_cash); + ("unsettled_cash", money valuation.unsettled_cash); ( "cash_balances", `List (List.map cash_attribution_to_yojson valuation.cash_balances) ); ( "positions", @@ -254,10 +712,30 @@ let margin_to_yojson margin = ("margin_call", `Bool margin.margin_call); ] +let group_exposure_to_yojson (exposure : Risk.group_exposure) = + `Assoc + [ + ("group_id", string (Id.Risk_group.to_string exposure.group_id)); + ("gross_exposure", money exposure.gross_exposure); + ("net_exposure", money exposure.net_exposure); + ("long_exposure", money exposure.long_exposure); + ("short_exposure", money exposure.short_exposure); + ( "concentration", + Option.fold ~none:`Null ~some:weight exposure.concentration ); + ] + let valuation_to_yojson valuation = match account_valuation_to_yojson valuation.Audit.account with | `Assoc fields -> - `Assoc (fields @ [ ("margin", margin_to_yojson valuation.margin) ]) + `Assoc + (fields + @ [ + ("margin", margin_to_yojson valuation.margin); + ( "group_exposures", + `List + (List.map group_exposure_to_yojson + valuation.margin.Risk.group_exposures) ); + ]) | _ -> assert false let order_counts_to_yojson counts = @@ -270,6 +748,30 @@ let order_counts_to_yojson counts = ("cancelled", `Int counts.cancelled); ] +let settlement_instruction_to_yojson instruction = + let settled_at, failed_at, failure_reason = + match instruction.Settlement.status with + | Settlement.Pending -> (`Null, `Null, `Null) + | Settlement.Settled value -> (timestamp value, `Null, `Null) + | Settlement.Failed { failed_at; reason } -> + (`Null, timestamp failed_at, string reason) + in + `Assoc + [ + ("instruction_id", string instruction.instruction_id); + ("fill_id", string (Id.Fill.to_string instruction.fill_id)); + ("instrument_id", instrument_id instruction.instrument_id); + ("currency", string instruction.currency); + ("cash_movement", money instruction.cash_movement); + ("position_movement", quantity instruction.position_movement); + ("trade_date", string instruction.trade_date); + ("due_date", string instruction.due_date); + ("status", string (Settlement.status_to_string instruction.status)); + ("settled_at", settled_at); + ("failed_at", failed_at); + ("failure_reason", failure_reason); + ] + let requested_target_to_yojson target = `Assoc [ @@ -280,6 +782,41 @@ let requested_target_to_yojson target = Option.fold ~none:`Null ~some:price target.reference_price ); ] +let metric_to_yojson metric = + let value = + match metric.Metric.value with + | Metric.Numeric value -> + `Assoc + [ + ("type", string "numeric"); + ("value", string (Metric.numeric_to_string value)); + ] + | Metric.String value -> + `Assoc [ ("type", string "string"); ("value", string value) ] + | Metric.Boolean value -> + `Assoc [ ("type", string "boolean"); ("value", `Bool value) ] + in + `Assoc + ([ ("name", string metric.name); ("value", value) ] + @ (match metric.unit_ with + | None -> [] + | Some unit_ -> [ ("unit", string unit_) ]) + @ (if metric.dimensions = [] then [] + else + [ + ( "dimensions", + `Assoc + (List.map + (fun dimension -> + (dimension.Metric.key, string dimension.value)) + metric.dimensions) ); + ]) + @ + match metric.aggregation with + | None -> [] + | Some aggregation -> + [ ("aggregation", string (Metric.aggregation_to_string aggregation)) ]) + let payload_to_yojson = function | Audit.Run_started { scenario_sha256; execution_model } -> `Assoc @@ -287,6 +824,12 @@ let payload_to_yojson = function ("scenario_sha256", string scenario_sha256); ("execution_model", string execution_model); ] + | Audit.Initial_state { portfolio; valuation } -> + `Assoc + [ + ("portfolio", initial_portfolio_to_yojson portfolio); + ("valuation", valuation_to_yojson valuation); + ] | Audit.Market_slice_received market_slice -> market_slice_to_yojson market_slice | Audit.Target_portfolio_requested { basis; targets } -> @@ -297,6 +840,7 @@ let payload_to_yojson = function ] | Audit.Order_accepted order | Audit.Order_rejected order -> order_to_yojson order + | Audit.Order_triggered order -> order_to_yojson order | Audit.Order_cancelled { order; reason } -> `Assoc [ @@ -317,56 +861,134 @@ let payload_to_yojson = 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 [ ("order", order_to_yojson order); ("action_id", string (Id.Corporate_action.to_string action_id)); ] + | Audit.Execution_price_selected + { order_id = id; instrument_id = instrument; side; attribution } -> + `Assoc + [ + ("order_id", order_id id); + ("instrument_id", instrument_id instrument); + ("side", string (Order.side_to_string side)); + ("reference_price", price attribution.Execution.reference_price); + ("spread_adjustment", money attribution.spread_adjustment); + ("impact_adjustment", money attribution.impact_adjustment); + ("final_price", price attribution.final_price); + ] | Audit.Fill_applied fill -> fill_to_yojson fill - | Audit.Margin_limited + | Audit.Settlement_instruction_created instruction + | Audit.Settlement_completed instruction + | Audit.Settlement_failed instruction -> + settlement_instruction_to_yojson instruction + | Audit.Fill_clipped { order_id = id; instrument_id = instrument; - requested_quantity; + proposed_quantity; permitted_quantity; price = fill_price; + limit; } -> + let limiting_policy, threshold = fill_limit_to_yojson limit in `Assoc [ + ( "reason", + `Assoc + [ + ("version", string "1"); + ("policy", string limiting_policy); + ("threshold", threshold); + ] ); ("order_id", order_id id); ("instrument_id", instrument_id instrument); - ("requested_quantity", quantity requested_quantity); + ("proposed_quantity", quantity proposed_quantity); ("permitted_quantity", quantity permitted_quantity); ("price", price fill_price); ] - | Audit.Borrow_fee_applied + | Audit.Borrow_charge_applied { - instrument_id = instrument; + observation; quote_currency; short_quantity; reference_price; - borrow_bps; + day_count; + compounding; period_start; period_end; - fee; + amount; } -> `Assoc [ - ("instrument_id", instrument_id instrument); + ("observation", borrow_observation_to_yojson observation); ("quote_currency", string quote_currency); ("short_quantity", quantity short_quantity); ("reference_price", price reference_price); - ("borrow_bps", `Int borrow_bps); + ("day_count", string (Financing.day_count_to_string day_count)); + ("compounding", string (Financing.compounding_to_string compounding)); + ("period_start", timestamp period_start); + ("period_end", timestamp period_end); + ("amount", money amount); + ] + | Audit.Borrow_recall_received + { observation; short_quantity; close_out_quantity } -> + `Assoc + [ + ("observation", borrow_observation_to_yojson observation); + ("short_quantity", quantity short_quantity); + ("close_out_quantity", quantity close_out_quantity); + ] + | Audit.Cash_interest_applied + { + observation; + opening_balance; + applied_rate_bps; + day_count; + compounding; + period_start; + period_end; + amount; + closing_balance; + } -> + `Assoc + [ + ("observation", cash_rate_observation_to_yojson observation); + ("opening_balance", money opening_balance); + ("applied_rate_bps", `Int applied_rate_bps); + ("day_count", string (Financing.day_count_to_string day_count)); + ("compounding", string (Financing.compounding_to_string compounding)); ("period_start", timestamp period_start); ("period_end", timestamp period_end); - ("fee", money fee); + ("amount", money amount); + ("closing_balance", money closing_balance); ] | Audit.Margin_call_triggered valuation | Audit.Margin_restored valuation -> valuation_to_yojson valuation | Audit.Intent_rejected reason -> `Assoc [ ("reason", string reason) ] - | Audit.Metric_emitted { name; value } -> - `Assoc [ ("name", string name); ("value", string value) ] + | Audit.Metric_emitted metric -> metric_to_yojson metric | Audit.Valuation valuation -> valuation_to_yojson valuation | Audit.Run_completed { scenario_sha256; execution_model; valuation; order_counts } -> diff --git a/lib/codec.mli b/lib/codec.mli index aee2617..86ff0a9 100644 --- a/lib/codec.mli +++ b/lib/codec.mli @@ -6,5 +6,7 @@ val bar_to_yojson : Bar.t -> Yojson.Safe.t val market_slice_to_yojson : Market_slice.t -> Yojson.Safe.t val order_to_yojson : Order.t -> Yojson.Safe.t val fill_to_yojson : Fill.t -> Yojson.Safe.t +val initial_portfolio_to_yojson : Initial_portfolio.t -> Yojson.Safe.t +val account_valuation_to_yojson : Account.valuation -> Yojson.Safe.t val audit_to_yojson : Audit.t -> Yojson.Safe.t val audit_to_string : Audit.t -> string diff --git a/lib/contract.ml b/lib/contract.ml index 05de043..202d0bb 100644 --- a/lib/contract.ml +++ b/lib/contract.ml @@ -1,18 +1,22 @@ -let version = "3" -let strategy_protocol_version = "3" -let engine_version = "1.0.0" +let version = "1" +let supported_versions = [ version ] +let is_supported version = List.mem version supported_versions +let strategy_protocol_version = "1" +let engine_version = "1.1.0" let strings values = `List (List.map (fun value -> `String value) values) let capabilities_to_yojson () = `Assoc [ ("engine_version", `String engine_version); - ("scenario_contract_versions", strings [ version ]); - ("journal_contract_versions", strings [ version ]); + ("scenario_contract_versions", strings supported_versions); + ("journal_contract_versions", strings supported_versions); ("scenario_formats", strings [ "json"; "jsonl" ]); ("journal_formats", strings [ "jsonl" ]); ("execution_models", strings Execution_model.supported); + ("execution_model_contracts", Execution_model.capabilities_to_yojson ()); ("strategy_protocol_versions", strings [ strategy_protocol_version ]); + ("resource_limits", Resource_limits.to_yojson ()); ] let capabilities_to_string () = diff --git a/lib/contract.mli b/lib/contract.mli index a81e2e8..7e18e56 100644 --- a/lib/contract.mli +++ b/lib/contract.mli @@ -1,6 +1,8 @@ (** Version and capability identifiers for the process/file boundary. *) val version : string +val supported_versions : string list +val is_supported : string -> bool val strategy_protocol_version : string val engine_version : string val capabilities_to_yojson : unit -> Yojson.Safe.t 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/diagnostic.ml b/lib/diagnostic.ml new file mode 100644 index 0000000..45dfb95 --- /dev/null +++ b/lib/diagnostic.ml @@ -0,0 +1,184 @@ +let version = "1" + +type code = + | Cli_invalid_arguments + | Input_io + | Scenario_invalid_json + | Scenario_invalid + | Scenario_unsupported_contract + | Scenario_stream_invalid + | Scenario_stream_changed + | Resource_limit + | Replay_failed + | Reducer_failed + | Strategy_invalid_configuration + | Strategy_protocol + | Strategy_timeout + | Strategy_process + | Strategy_exit + | Artifact_exists + | Artifact_io + | Artifact_state + +type phase = Cli | Input | Validation | Replay | Reducer | Strategy | Artifact + +type cause = { + kind : string; + message : string; + operation : string option; + target : string option; +} + +type context = { + json_path : string option; + line : int option; + sequence : int64 option; + event_id : string option; + order_id : string option; + causation_ids : string list; +} + +type t = { + code : code; + phase : phase; + message : string; + context : context; + cause : cause option; +} + +let code_to_string = function + | Cli_invalid_arguments -> "cli.invalid_arguments" + | Input_io -> "input.io" + | Scenario_invalid_json -> "scenario.invalid_json" + | Scenario_invalid -> "scenario.invalid" + | Scenario_unsupported_contract -> "scenario.unsupported_contract" + | Scenario_stream_invalid -> "scenario_stream.invalid" + | Scenario_stream_changed -> "scenario_stream.changed" + | Resource_limit -> "resource.limit" + | Replay_failed -> "replay.failed" + | Reducer_failed -> "reducer.failed" + | Strategy_invalid_configuration -> "strategy.invalid_configuration" + | Strategy_protocol -> "strategy.protocol" + | Strategy_timeout -> "strategy.timeout" + | Strategy_process -> "strategy.process" + | Strategy_exit -> "strategy.exit" + | Artifact_exists -> "artifact.exists" + | Artifact_io -> "artifact.io" + | Artifact_state -> "artifact.state" + +let phase_to_string = function + | Cli -> "cli" + | Input -> "input" + | Validation -> "validation" + | Replay -> "replay" + | Reducer -> "reducer" + | Strategy -> "strategy" + | Artifact -> "artifact" + +let cause_of_exception = function + | Unix.Unix_error (code, operation, target) -> + { + kind = "unix_error"; + message = Unix.error_message code; + operation = Some operation; + target = Some target; + } + | Sys_error message -> + { kind = "system_error"; message; operation = None; target = None } + | exception_ -> + { + kind = "exception"; + message = Printexc.to_string exception_; + operation = None; + target = None; + } + +let context ?json_path ?line ?sequence ?event_id ?order_id ?(causation_ids = []) + () = + { json_path; line; sequence; event_id; order_id; causation_ids } + +let make ?json_path ?line ?sequence ?event_id ?order_id ?causation_ids ?cause + ~code ~phase message = + { + code; + phase; + message; + context = + context ?json_path ?line ?sequence ?event_id ?order_id ?causation_ids (); + cause; + } + +let of_exception ?json_path ?line ?sequence ~code ~phase ~message exception_ = + make ?json_path ?line ?sequence + ~cause:(cause_of_exception exception_) + ~code ~phase message + +let annotate ?json_path ?line ?sequence ?event_id ?order_id ?causation_ids + diagnostic = + let choose supplied existing = + match existing with Some _ -> existing | None -> supplied + in + let context = diagnostic.context in + { + diagnostic with + context = + { + json_path = choose json_path context.json_path; + line = choose line context.line; + sequence = choose sequence context.sequence; + event_id = choose event_id context.event_id; + order_id = choose order_id context.order_id; + causation_ids = + Option.value causation_ids ~default:context.causation_ids; + }; + } + +let combine primary secondary = + { + primary with + message = primary.message ^ "; " ^ secondary.message; + cause = + (match primary.cause with + | Some _ as cause -> cause + | None -> secondary.cause); + } + +let optional name value encode = + match value with None -> [] | Some value -> [ (name, encode value) ] + +let context_to_yojson context = + `Assoc + (optional "json_path" context.json_path (fun value -> `String value) + @ optional "line" context.line (fun value -> `Int value) + @ optional "sequence" context.sequence (fun value -> + `String (Int64.to_string value)) + @ optional "event_id" context.event_id (fun value -> `String value) + @ optional "order_id" context.order_id (fun value -> `String value) + @ + if context.causation_ids = [] then [] + else + [ + ( "causation_ids", + `List (List.map (fun value -> `String value) context.causation_ids) ); + ]) + +let cause_to_yojson cause = + `Assoc + ([ ("kind", `String cause.kind); ("message", `String cause.message) ] + @ optional "operation" cause.operation (fun value -> `String value) + @ optional "target" cause.target (fun value -> `String value)) + +let to_yojson diagnostic = + `Assoc + [ + ("diagnostic_version", `String version); + ("code", `String (code_to_string diagnostic.code)); + ("phase", `String (phase_to_string diagnostic.phase)); + ("message", `String diagnostic.message); + ("context", context_to_yojson diagnostic.context); + ("cause", Option.fold ~none:`Null ~some:cause_to_yojson diagnostic.cause); + ] + +let to_json diagnostic = to_yojson diagnostic |> Yojson.Safe.to_string +let to_human diagnostic = diagnostic.message +let pp formatter diagnostic = Fmt.string formatter (to_human diagnostic) diff --git a/lib/diagnostic.mli b/lib/diagnostic.mli new file mode 100644 index 0000000..2358502 --- /dev/null +++ b/lib/diagnostic.mli @@ -0,0 +1,95 @@ +(** Stable structured errors for process and file boundaries. *) + +val version : string + +type code = + | Cli_invalid_arguments + | Input_io + | Scenario_invalid_json + | Scenario_invalid + | Scenario_unsupported_contract + | Scenario_stream_invalid + | Scenario_stream_changed + | Resource_limit + | Replay_failed + | Reducer_failed + | Strategy_invalid_configuration + | Strategy_protocol + | Strategy_timeout + | Strategy_process + | Strategy_exit + | Artifact_exists + | Artifact_io + | Artifact_state + +type phase = Cli | Input | Validation | Replay | Reducer | Strategy | Artifact + +type cause = private { + kind : string; + message : string; + operation : string option; + target : string option; +} + +type context = private { + json_path : string option; + line : int option; + sequence : int64 option; + event_id : string option; + order_id : string option; + causation_ids : string list; +} + +type t = private { + code : code; + phase : phase; + message : string; + context : context; + cause : cause option; +} + +val cause_of_exception : exn -> cause + +val make : + ?json_path:string -> + ?line:int -> + ?sequence:int64 -> + ?event_id:string -> + ?order_id:string -> + ?causation_ids:string list -> + ?cause:cause -> + code:code -> + phase:phase -> + string -> + t + +val of_exception : + ?json_path:string -> + ?line:int -> + ?sequence:int64 -> + code:code -> + phase:phase -> + message:string -> + exn -> + t + +val annotate : + ?json_path:string -> + ?line:int -> + ?sequence:int64 -> + ?event_id:string -> + ?order_id:string -> + ?causation_ids:string list -> + t -> + t + +val combine : t -> t -> t +(** [combine primary secondary] preserves the primary identity and context while + adding the secondary message and an underlying cause when needed. *) + +val code_to_string : code -> string +val phase_to_string : phase -> string +val to_yojson : t -> Yojson.Safe.t +val to_json : t -> string +val to_human : t -> string +val pp : t Fmt.t diff --git a/lib/dune b/lib/dune index 2db4e27..6fbb4d3 100644 --- a/lib/dune +++ b/lib/dune @@ -1,4 +1,10 @@ (library (name trading_engine) (public_name trading_engine) + (private_modules reducer_phases scenario_shape scenario_validation) + (instrumentation + (backend bisect_ppx)) + (foreign_stubs + (language c) + (names process_tree_stubs)) (libraries ptime yojson zarith fmt logs unix eio eio.unix)) diff --git a/lib/engine.ml b/lib/engine.ml index a63b303..cbc4adf 100644 --- a/lib/engine.ml +++ b/lib/engine.ml @@ -1,14 +1,54 @@ +module Currency_map = Map.Make (String) + type config = { + 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; } -let config ~risk ~execution_model ~execution ~max_internal_events = - if max_internal_events <= 0 then +let config ~contract_version ~risk ~venue_calendars ~execution_model ~execution + ~financing ~settlement ~max_internal_events = + if not (Contract.is_supported contract_version) then + Error "engine contract version is unsupported" + else if + List.mem (Execution_model.name execution_model) Execution_model.supported + && not (Execution_model.supports_contract execution_model contract_version) + then Error "execution model does not support the engine contract version" + else if + String.equal (Execution_model.name execution_model) "completed_bar_v1" + && Option.is_some (Execution.cost_model execution) + || List.mem + (Execution_model.name execution_model) + [ "completed_bar_next_open_v1"; "completed_bar_adverse_touch_v1" ] + && Option.is_none (Execution.cost_model execution) + then Error "execution model and pricing configuration are incompatible" + else if + String.equal (Execution_model.name execution_model) "order_book_v1" + <> Option.is_some (Execution.book_depth_limit execution) + then Error "execution model and order-book configuration are incompatible" + else if max_internal_events <= 0 then Error "maximum internal events must be positive" - else Ok { risk; execution_model; execution; max_internal_events } + else if max_internal_events > Resource_limits.internal_events then + Error + (Printf.sprintf "maximum internal events is %d; limit is %d" + max_internal_events Resource_limits.internal_events) + else + Ok + { + contract_version; + risk; + venue_calendars; + execution_model; + execution; + financing; + settlement; + max_internal_events; + } let valid_sha256 value = String.length value = 64 @@ -36,8 +76,14 @@ module Interactive = struct last_slice_end : Ptime.t option; last_received_at : Ptime.t option; latest_bars : Bar.t Id.Instrument.Map.t; + latest_marks : Scalar.Price.t Id.Instrument.Map.t; latest_fx_rates : (string * Scalar.Price.t) list; + latest_borrow : Financing.borrow_observation Id.Instrument.Map.t; + latest_cash_rates : Financing.cash_rate_observation Currency_map.t; + settlement_instructions : Settlement.instruction list; + initial_portfolio : Initial_portfolio.t; applied_action_ids : Id.Corporate_action.Set.t; + lifecycle : Instrument_lifecycle.t; desired_targets : desired_targets option; liquidation_pending : bool; account : Account.t; @@ -50,6 +96,26 @@ module Interactive = struct | Notify of Id.Event.t list * Strategy.event | Act of Id.Event.t list * Strategy.intent + module Pending_queue = struct + type t = { front : pending list; back : pending list } + + let empty = { front = []; back = [] } + let is_empty queue = queue.front = [] && queue.back = [] + + let enqueue queue items = + { queue with back = List.rev_append items queue.back } + + let prepend queue items = { queue with front = items @ queue.front } + + let pop queue = + match queue.front with + | item :: front -> Some (item, { queue with front }) + | [] -> ( + match List.rev queue.back with + | [] -> None + | item :: front -> Some (item, { front; back = [] })) + end + type reduction = { state : t; now : Ptime.t; @@ -57,59 +123,80 @@ module Interactive = struct slice_event_id : Id.Event.t option; causation_ids : Id.Event.t list; audits_rev : Audit.t list; - pending : pending list; + pending : Pending_queue.t; processed : int; } - let create ~run_id ~scenario_sha256 ~config ~initial_cash = + 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; + scenario_sha256; + config; + engine_sequence = 0L; + next_order_number = 1L; + next_fill_number = 1L; + last_slice_sequence = None; + last_slice_end = None; + last_received_at = None; + latest_bars = Id.Instrument.Map.empty; + latest_marks; + latest_fx_rates; + latest_borrow = Id.Instrument.Map.empty; + latest_cash_rates = Currency_map.empty; + settlement_instructions = []; + initial_portfolio; + applied_action_ids = Id.Corporate_action.Set.empty; + lifecycle; + desired_targets = None; + liquidation_pending = false; + account; + oms = Oms.empty; + started = false; + completed = false; + } + + let expected_currencies config = + Risk.base_currency config.risk + :: List.map + (fun instrument -> instrument.Instrument.quote_currency) + (Risk.instruments config.risk) + |> List.sort_uniq String.compare + + let create ~run_id ~scenario_sha256 ~config ~initial_portfolio = if not (valid_sha256 scenario_sha256) then Error "scenario SHA-256 must contain 64 lowercase hexadecimal characters" + else if + not + (String.equal initial_portfolio.Initial_portfolio.base_currency + (Risk.base_currency config.risk)) + then Error "initial portfolio base currency differs from risk configuration" else - let expected_currencies = - Risk.base_currency config.risk - :: List.map - (fun instrument -> instrument.Instrument.quote_currency) - (Risk.instruments config.risk) - |> List.sort_uniq String.compare + let supplied = + List.map fst initial_portfolio.cash |> List.sort_uniq String.compare in - let supplied_currencies = - List.map fst initial_cash |> List.sort_uniq String.compare - in - if supplied_currencies <> expected_currencies then + if supplied <> expected_currencies config then Error "initial cash must contain every configured currency exactly once" else - match - Account.create - ~base_currency:(Risk.base_currency config.risk) - ~initial_cash - with - | Error _ as error -> error - | Ok account -> - let base_rate = - Scalar.Price.of_decimal_string "1" |> Result.get_ok - in - Ok - { - run_id; - scenario_sha256; - config; - engine_sequence = 0L; - next_order_number = 1L; - next_fill_number = 1L; - last_slice_sequence = None; - last_slice_end = None; - last_received_at = None; - latest_bars = Id.Instrument.Map.empty; - latest_fx_rates = - [ (Risk.base_currency config.risk, base_rate) ]; - applied_action_ids = Id.Corporate_action.Set.empty; - desired_targets = None; - liquidation_pending = false; - account; - oms = Oms.empty; - started = false; - completed = false; - } + let* account = Account.of_initial_portfolio initial_portfolio in + let latest_marks = + List.fold_left + (fun marks (instrument_id, price) -> + Id.Instrument.Map.add instrument_id price marks) + Id.Instrument.Map.empty initial_portfolio.marks + in + let* valuation = + Account.value account + ~instruments:(Risk.instruments config.risk) + ~marks:initial_portfolio.marks ~fx_rates:initial_portfolio.fx_rates + in + let* () = Risk.check_initial config.risk valuation in + create_state ~run_id ~scenario_sha256 ~config ~account ~latest_marks + ~latest_fx_rates:initial_portfolio.fx_rates ~initial_portfolio let account state = state.account let oms state = state.oms @@ -134,7 +221,8 @@ module Interactive = struct Audit.event_id ~run_id:reduction.state.run_id ~engine_sequence in let audit = - Audit.create ~engine_sequence + Audit.create ~contract_version:reduction.state.config.contract_version + ~engine_sequence ~causation_ids:(normalize_causes reduction.causation_ids) ~run_id:reduction.state.run_id ~recorded_at:reduction.now event in @@ -145,43 +233,51 @@ module Interactive = struct let emit reduction event = emit_with_id reduction event |> Result.map fst + let value state = + Account.value state.account + ~instruments:(Risk.instruments state.config.risk) + ~marks:(Id.Instrument.Map.bindings state.latest_marks) + ~fx_rates:state.latest_fx_rates + let ensure_started reduction = if reduction.state.started then Ok reduction else let state = { reduction.state with started = true } in - emit_with_id - (with_causes { reduction with state } []) - (Audit.Run_started - { - scenario_sha256 = reduction.state.scenario_sha256; - execution_model = - Execution_model.name reduction.state.config.execution_model; - }) - |> Result.map (fun (reduction, event_id) -> - with_causes reduction [ event_id ]) + let* reduction, event_id = + emit_with_id + (with_causes { reduction with state } []) + (Audit.Run_started + { + scenario_sha256 = reduction.state.scenario_sha256; + execution_model = + Execution_model.name reduction.state.config.execution_model; + }) + in + let reduction = with_causes reduction [ event_id ] in + let portfolio = reduction.state.initial_portfolio in + let* account = value reduction.state in + let* margin = Risk.margin_snapshot reduction.state.config.risk account in + let valuation = Audit.{ account; margin } in + let* reduction, initial_event_id = + emit_with_id reduction (Audit.Initial_state { portfolio; valuation }) + in + emit + (with_causes reduction [ initial_event_id ]) + (Audit.Valuation valuation) let enqueue reduction items = - { reduction with pending = reduction.pending @ items } + { reduction with pending = Pending_queue.enqueue reduction.pending items } let prepend reduction items = - { reduction with pending = items @ reduction.pending } - - let value state = - let marks = - Id.Instrument.Map.bindings state.latest_bars - |> List.map (fun (instrument_id, bar) -> - (instrument_id, bar.Bar.close_price)) - in - Account.value state.account - ~instruments:(Risk.instruments state.config.risk) - ~marks ~fx_rates:state.latest_fx_rates + { reduction with pending = Pending_queue.prepend reduction.pending items } let strategy_context state now = let latest_bars = Id.Instrument.Map.bindings state.latest_bars |> List.map snd in let* valuation = value state in - Strategy.context ~now ~valuation + let* group_exposures = Risk.group_exposures state.config.risk valuation in + Strategy.context ~now ~valuation ~group_exposures ~working_orders:(Oms.active_orders state.oms) ~latest_bars @@ -241,10 +337,81 @@ 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 } -> ( + match + List.find_opt + (fun calendar -> + Id.Venue_calendar.equal calendar.Venue_calendar.id + calendar_id) + reduction.state.config.venue_calendars + with + | None -> Error "DAY order refers to an unknown calendar" + | Some calendar -> + if not (Id.Venue.equal calendar.venue_id venue_id) then + Error "DAY order venue differs from its calendar" + else if + not + (Id.Instrument.Set.mem request.instrument_id + calendar.instrument_ids) + then + Error + "DAY order calendar does not cover its instrument" + else Ok ()) + | Order.Gtc | Order.Ioc | Order.Fok | Order.Gtd _ -> Ok () + in let marks = - Id.Instrument.Map.bindings reduction.state.latest_bars - |> List.map (fun (instrument_id, bar) -> - (instrument_id, bar.Bar.close_price)) + Id.Instrument.Map.bindings reduction.state.latest_marks + in + let* () = + let policy = reduction.state.config.financing in + match + ( request.Order.side, + Account.position_quantity reduction.state.account + request.instrument_id ) + with + | Order.Sell, position + when policy.Financing.locate_policy = Financing.Reject_order + && not (Scalar.Quantity.is_positive position) -> + let available = + match + Id.Instrument.Map.find_opt request.instrument_id + reduction.state.latest_borrow + with + | Some observation when not observation.Financing.recalled + -> + observation.available_quantity + | None | Some _ -> Scalar.Quantity.zero + in + let* located = Scalar.Quantity.absolute position in + let* reserved = + Oms.active_for_instrument reduction.state.oms + request.instrument_id + |> List.fold_left + (fun result order -> + let* total = result in + if order.Order.request.side = Order.Sell then + Scalar.Quantity.add total + (Order.remaining_quantity order) + else Ok total) + (Ok Scalar.Quantity.zero) + in + let* requested = Scalar.Quantity.add located reserved in + let* requested = + Scalar.Quantity.add requested request.quantity + in + if Scalar.Quantity.compare requested available > 0 then + Error "order exceeds effective borrow availability" + else Ok () + | _ -> Ok () in Risk.check reduction.state.config.risk ~account:reduction.state.account ~oms:reduction.state.oms ~marks @@ -301,6 +468,38 @@ module Interactive = struct (enqueue reduction [ order_pending; rejection_pending ]))))) + let submit_recall_order reduction market_slice instrument quantity = + let* request = + Order.request ~instrument_id:instrument.Instrument.id ~side:Order.Buy + ~quantity ~kind:Order.Market ~time_in_force:Order.Ioc + ~origin:Order.Borrow_recall + in + let id = order_id reduction.state in + let* state = increment_order_number reduction.state in + let reduction = { reduction with state } in + let* order_sequence = next_sequence reduction.state.engine_sequence in + let created_event_id = + Audit.event_id ~run_id:reduction.state.run_id + ~engine_sequence:order_sequence + in + let eligible_after_slice_sequence = + Int64.pred market_slice.Market_slice.slice_sequence + in + let* oms, order = + Oms.accept reduction.state.oms ~id ~created_event_id + ~accepted_sequence:order_sequence ~created_at:market_slice.start_at + ~eligible_after_slice_sequence request + in + let reduction = { reduction with state = { reduction.state with oms } } in + let* reduction, event_id = + emit_with_id reduction (Audit.Order_accepted order) + in + let* pending = + notification reduction ~causation_ids:[ event_id ] + (Strategy.Order_updated order) + in + Ok (enqueue reduction [ pending ]) + let cancel_order reduction ~reason order_id = match Oms.cancel reduction.state.oms order_id with | Error message -> reject_intent reduction message @@ -335,6 +534,33 @@ module Interactive = struct in cancel reduction order_ids + let trigger_order reduction order_id ~triggered_at ~triggered_slice_sequence = + let* sequence = next_sequence reduction.state.engine_sequence in + let updated_event_id = + Audit.event_id ~run_id:reduction.state.run_id ~engine_sequence:sequence + in + let* oms, order = + Oms.trigger reduction.state.oms order_id ~updated_event_id ~triggered_at + ~triggered_slice_sequence + in + let reduction = + { reduction with state = { reduction.state with oms } } + |> fun reduction -> + with_causes reduction + (order.Order.created_event_id :: reduction.causation_ids) + in + let* reduction, emitted_id = + emit_with_id reduction (Audit.Order_triggered order) + in + if not (Id.Event.equal emitted_id updated_event_id) then + Error "order trigger event ID prediction diverged" + else + let* pending = + notification reduction ~causation_ids:[ emitted_id ] + (Strategy.Order_updated order) + in + Ok (enqueue reduction [ pending ]) + let configured_instruments state = Risk.instruments state.config.risk |> List.sort (fun left right -> @@ -360,7 +586,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 @@ -422,12 +649,23 @@ module Interactive = struct else match order.request.kind with | Order.Market -> Ok () - | Order.Limit price -> + | Order.Limit price | Order.Stop price -> if Scalar.Price.is_multiple price ~tick:instrument.tick_size then Ok () else Error - "split-adjusted limit price is not aligned to the \ + "split-adjusted order price is not aligned to the \ + instrument tick" + | Order.Stop_limit { trigger_price; limit_price } -> + if + Scalar.Price.is_multiple trigger_price + ~tick:instrument.tick_size + && Scalar.Price.is_multiple limit_price + ~tick:instrument.tick_size + then Ok () + else + Error + "split-adjusted order price is not aligned to the \ instrument tick") (Ok ()) adjusted in @@ -468,6 +706,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 -> @@ -478,9 +805,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 @@ -510,7 +944,7 @@ module Interactive = struct if Z.fits_int64 fee then Ok (Scalar.Money.of_micros (Z.to_int64 fee)) else Error "short borrow fee overflow" - let apply_borrow_fees reduction market_slice = + let apply_observed_borrow_fees reduction market_slice policy = let span = Ptime.diff market_slice.Market_slice.end_at market_slice.start_at in @@ -521,47 +955,249 @@ module Interactive = struct Account.position_quantity reduction.state.account instrument.Instrument.id in - if - (not (Scalar.Quantity.is_negative quantity)) - || Risk.short_borrow_bps reduction.state.config.risk = 0 - then Ok reduction + if not (Scalar.Quantity.is_negative quantity) then Ok reduction else - let* short_quantity = Scalar.Quantity.absolute quantity in - let* bar = - match Market_slice.bar market_slice instrument.id with - | Some value -> Ok value - | None -> Error "short position has no market slice bar" - in - let* notional = Scalar.Money.notional bar.open_price short_quantity in - let borrow_bps = Risk.short_borrow_bps reduction.state.config.risk in - let* fee = borrow_fee ~notional ~bps:borrow_bps span in - if Scalar.Money.equal fee Scalar.Money.zero then Ok reduction - else - let* account = - Account.apply_borrow_fee reduction.state.account - ~instrument_id:instrument.id - ~quote_currency:instrument.quote_currency ~fee - in - let reduction = - { reduction with state = { reduction.state with account } } + match + Id.Instrument.Map.find_opt instrument.id + reduction.state.latest_borrow + with + | None -> ( + match policy.Financing.borrow_missing_data with + | Financing.Zero -> Ok reduction + | Financing.Reject -> + Error + (Format.asprintf + "open short has no effective borrow observation for %a" + Id.Instrument.pp instrument.id)) + | Some observation -> + let* short_quantity = Scalar.Quantity.absolute quantity in + let* bar = + match Market_slice.bar market_slice instrument.id with + | Some value -> Ok value + | None -> Error "short position has no market slice bar" + in + let* notional = + Scalar.Money.notional bar.open_price short_quantity + in + let* amount = + Financing.accrue policy ~principal:notional + ~annual_rate_bps:observation.annual_rate_bps span + in + if Scalar.Money.equal amount Scalar.Money.zero then Ok reduction + else + let* account = + Account.apply_borrow_fee reduction.state.account + ~instrument_id:instrument.id + ~quote_currency:instrument.quote_currency ~fee:amount + in + let reduction = + { reduction with state = { reduction.state with account } } + in + emit + (with_causes reduction + (Option.to_list reduction.slice_event_id)) + (Audit.Borrow_charge_applied + { + observation; + quote_currency = instrument.quote_currency; + short_quantity; + reference_price = bar.open_price; + day_count = policy.day_count; + compounding = policy.compounding; + period_start = market_slice.start_at; + period_end = market_slice.end_at; + amount; + })) + (Ok reduction) + (configured_instruments reduction.state) + + let apply_cash_interest reduction market_slice policy = + let span = + Ptime.diff market_slice.Market_slice.end_at market_slice.start_at + in + List.fold_left + (fun result (currency, opening_balance) -> + let* reduction = result in + if Scalar.Money.equal opening_balance Scalar.Money.zero then + Ok reduction + else + match + Currency_map.find_opt currency reduction.state.latest_cash_rates + with + | None -> ( + match policy.Financing.cash_missing_data with + | Financing.Zero -> Ok reduction + | Financing.Reject -> + Error + ("nonzero cash balance has no effective rate for currency " + ^ currency)) + | Some observation -> + let debit = + Scalar.Money.compare opening_balance Scalar.Money.zero < 0 + in + let applied_rate_bps = + if debit then observation.debit_rate_bps + else observation.credit_rate_bps + in + let* principal = + if debit then Scalar.Money.negate opening_balance + else Ok opening_balance + in + let* accrued = + Financing.accrue policy ~principal + ~annual_rate_bps:applied_rate_bps span + in + let* amount = + if debit then Scalar.Money.negate accrued else Ok accrued + in + if Scalar.Money.equal amount Scalar.Money.zero then Ok reduction + else + let* account = + Account.apply_cash_interest reduction.state.account ~currency + ~interest:amount + in + let* closing_balance = + match Account.cash account currency with + | Some value -> Ok value + | None -> Error "cash interest removed its currency ledger" + in + let reduction = + { reduction with state = { reduction.state with account } } + in + emit + (with_causes reduction + (Option.to_list reduction.slice_event_id)) + (Audit.Cash_interest_applied + { + observation; + opening_balance; + applied_rate_bps; + day_count = policy.day_count; + compounding = policy.compounding; + period_start = market_slice.start_at; + period_end = market_slice.end_at; + amount; + closing_balance; + })) + (Ok reduction) + (Account.cash_balances reduction.state.account) + + let process_borrow_recalls reduction market_slice policy = + List.fold_left + (fun result instrument -> + let* reduction = result in + match + Id.Instrument.Map.find_opt instrument.Instrument.id + reduction.state.latest_borrow + with + | None | Some { Financing.recalled = false; _ } -> Ok reduction + | Some observation -> + let quantity = + Account.position_quantity reduction.state.account instrument.id in - let causes = Option.to_list reduction.slice_event_id in - emit - (with_causes reduction causes) - (Audit.Borrow_fee_applied - { - instrument_id = instrument.id; - quote_currency = instrument.quote_currency; - short_quantity; - reference_price = bar.open_price; - borrow_bps; - period_start = market_slice.start_at; - period_end = market_slice.end_at; - fee; - })) + if not (Scalar.Quantity.is_negative quantity) then Ok reduction + else + let* short_quantity = Scalar.Quantity.absolute quantity in + let close_out_quantity = + match policy.Financing.recall_policy with + | Financing.Reject_new_shorts -> Scalar.Quantity.zero + | Financing.Close_out -> short_quantity + in + let* reduction, recall_event_id = + emit_with_id + (with_causes reduction + (Option.to_list reduction.slice_event_id)) + (Audit.Borrow_recall_received + { observation; short_quantity; close_out_quantity }) + in + let active_sells = + Oms.active_for_instrument reduction.state.oms instrument.id + |> List.filter_map (fun order -> + if order.Order.request.side = Order.Sell then Some order.id + else None) + in + let* reduction = + cancel_orders + (with_causes reduction [ recall_event_id ]) + ~reason:Audit.Borrow_recall active_sells + in + if Scalar.Quantity.is_zero close_out_quantity then Ok reduction + else + submit_recall_order + (with_causes reduction [ recall_event_id ]) + market_slice instrument close_out_quantity) (Ok reduction) (configured_instruments reduction.state) + let apply_financing reduction market_slice = + let policy = reduction.state.config.financing in + let* reduction = process_borrow_recalls reduction market_slice policy in + let* reduction = apply_observed_borrow_fees reduction market_slice policy in + apply_cash_interest reduction market_slice policy + + let process_settlements reduction (market_slice : Market_slice.t) = + List.fold_left + (fun result (instruction : Settlement.instruction) -> + let* reduction = result in + match instruction.status with + | Settlement.Settled _ | Settlement.Failed _ -> Ok reduction + | Settlement.Pending -> + if not (Settlement.is_due instruction market_slice.start_at) then + Ok reduction + else + let failure = + List.find_opt + (fun (failure : Settlement.failure) -> + String.equal failure.instruction_id + instruction.instruction_id) + market_slice.Market_slice.settlement_failures + in + let* instruction, account, event = + match failure with + | Some failure -> + let* instruction = + Settlement.fail instruction + ~failed_at:market_slice.start_at ~reason:failure.reason + in + Ok + ( instruction, + reduction.state.account, + Audit.Settlement_failed instruction ) + | None -> + let* account = + Account.apply_settlement reduction.state.account + instruction + in + let* instruction = + Settlement.settle instruction + ~settled_at:market_slice.start_at + in + Ok + ( instruction, + account, + Audit.Settlement_completed instruction ) + in + let settlement_instructions = + List.map + (fun (current : Settlement.instruction) -> + if + String.equal current.instruction_id + instruction.instruction_id + then instruction + else current) + reduction.state.settlement_instructions + in + emit + (with_causes + { + reduction with + state = + { reduction.state with account; settlement_instructions }; + } + (Option.to_list reduction.slice_event_id)) + event) + (Ok reduction) reduction.state.settlement_instructions + let validate_target_ids state ids = let expected = configured_instruments state @@ -596,8 +1232,17 @@ 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 state.config.risk target.quantity in + let* () = + Risk.check_position_for state.config.risk target.instrument_id + target.quantity + in Ok ( Id.Instrument.Map.add target.instrument_id target.quantity desired, @@ -642,16 +1287,7 @@ module Interactive = struct > 0 then Error "target gross weight exceeds maximum leverage" else - let* valuation = - let marks = - Id.Instrument.Map.bindings state.latest_bars - |> List.map (fun (instrument_id, bar) -> - (instrument_id, bar.Bar.close_price)) - in - Account.value state.account - ~instruments:(Risk.instruments state.config.risk) - ~marks ~fx_rates:state.latest_fx_rates - in + let* valuation = value state in let add result (target : Strategy.weight_target) = let* desired, requested = result in match @@ -666,7 +1302,18 @@ module Interactive = struct ~weight:target.weight ~price:bar.close_price ~lot_size:instrument.lot_size in - let* () = Risk.check_position state.config.risk quantity 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 + in Ok ( Id.Instrument.Map.add target.instrument_id quantity desired, Audit. @@ -729,15 +1376,13 @@ module Interactive = struct | Ok (desired, requested) -> replace_targets reduction Audit.Weights desired requested - let metric reduction name value = - if String.length name = 0 || String.trim name <> name then - reject_intent reduction "metric name must be a nonempty trimmed string" - else emit reduction (Audit.Metric_emitted { name; value }) + let emit_metric reduction observation = + emit reduction (Audit.Metric_emitted observation) let handle_intent reduction = function | intent when reduction.state.liquidation_pending -> ( match intent with - | Strategy.Emit_metric { name; value } -> metric reduction name value + | Strategy.Emit_metric observation -> emit_metric reduction observation | _ -> reject_intent reduction "margin liquidation is in progress") | Strategy.Target_weights targets -> set_weight_targets reduction targets | Strategy.Target_quantities targets -> @@ -749,7 +1394,7 @@ module Interactive = struct else submit_order reduction request | Strategy.Cancel_order order_id -> cancel_order reduction ~reason:Audit.Strategy_requested order_id - | Strategy.Emit_metric { name; value } -> metric reduction name value + | Strategy.Emit_metric observation -> emit_metric reduction observation type drain_result = | Drained of reduction @@ -761,26 +1406,31 @@ module Interactive = struct } let rec drain reduction = - match reduction.pending with - | [] -> Ok (Drained reduction) - | _ when reduction.processed >= reduction.state.config.max_internal_events - -> - Error "maximum internal event count exceeded" - | item :: pending -> ( - let reduction = - { reduction with pending; processed = reduction.processed + 1 } - in - match item with - | Notify (causation_ids, event) -> - let reduction = with_causes reduction causation_ids in - let* context = strategy_context reduction.state reduction.now in - Ok (Strategy_requested { reduction; causation_ids; context; event }) - | Act (causation_ids, intent) -> ( - match - handle_intent (with_causes reduction causation_ids) intent - with - | Error _ as error -> error - | Ok reduction -> drain reduction)) + if Pending_queue.is_empty reduction.pending then Ok (Drained reduction) + else if reduction.processed >= reduction.state.config.max_internal_events + then + Error + (Printf.sprintf "internal event count exceeds configured limit of %d" + reduction.state.config.max_internal_events) + else + match Pending_queue.pop reduction.pending with + | None -> Ok (Drained reduction) + | Some (item, pending) -> ( + let reduction = + { reduction with pending; processed = reduction.processed + 1 } + in + match item with + | Notify (causation_ids, event) -> + let reduction = with_causes reduction causation_ids in + let* context = strategy_context reduction.state reduction.now in + Ok + (Strategy_requested { reduction; causation_ids; context; event }) + | Act (causation_ids, intent) -> ( + match + handle_intent (with_causes reduction causation_ids) intent + with + | Error _ as error -> error + | Ok reduction -> drain reduction)) let validate_slice state market_slice = let expected = @@ -815,10 +1465,72 @@ 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) -> + Option.is_some + (Risk.instrument state.config.risk observation.instrument_id) + && Ptime.compare observation.effective_at market_slice.start_at <= 0 + && + match + Id.Instrument.Map.find_opt observation.instrument_id + state.latest_borrow + with + | None -> true + | Some previous -> + Ptime.compare observation.effective_at previous.effective_at > 0) + market_slice.borrow_observations + in + let cash_observations_valid = + List.for_all + (fun (observation : Financing.cash_rate_observation) -> + List.mem observation.currency expected_currencies + && Ptime.compare observation.effective_at market_slice.start_at <= 0 + && + match + Currency_map.find_opt observation.currency state.latest_cash_rates + with + | None -> true + | Some previous -> + Ptime.compare observation.effective_at previous.effective_at > 0) + market_slice.cash_rate_observations + in + let settlement_failures_valid = + List.for_all + (fun (failure : Settlement.failure) -> + List.exists + (fun (instruction : Settlement.instruction) -> + String.equal instruction.instruction_id failure.instruction_id + && instruction.status = Settlement.Pending + && Settlement.is_due instruction market_slice.start_at) + state.settlement_instructions) + market_slice.settlement_failures + in if List.length ids <> List.length actual || actual <> expected then Error "market slice must contain each configured instrument exactly once" - else if actual_currencies <> expected_currencies then - Error "market slice must contain each configured currency FX rate" + else if + not + (List.for_all + (fun currency -> List.mem currency actual_currencies) + expected_currencies) + then Error "market slice must contain each configured currency FX rate" else if not (Option.exists @@ -828,6 +1540,14 @@ 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 + Error "cash rate observations must be known and advance effective time" + else if not settlement_failures_valid then + Error "settlement failures must reference due pending instructions" else match state.last_slice_sequence with | Some sequence @@ -844,12 +1564,19 @@ module Interactive = struct Error "market slice receipt time must not move backward" | _ -> Ok ())) - let fill_fee execution price quantity = + let fill_fee execution instrument market_slice liquidity price quantity = let* notional = Scalar.Money.notional price quantity in - Scalar.Money.fee - ~fixed:(Execution.fixed_fee execution) - ~bps:(Execution.fee_bps execution) - ~notional + Execution.calculate_fee execution ~instrument ~notional ~quantity ~liquidity + ~fx_rates: + (List.map + (fun mark -> (mark.Market_slice.currency, mark.rate)) + market_slice.Market_slice.fx_rates) + + let create_execution_fill execution ~id ~order_id ~instrument_id + ~quote_currency ~side ~quantity ~price ~fee ~fee_components ~executed_at + ~slice_sequence = + Fill.create ~id ~order_id ~instrument_id ~quote_currency ~side ~quantity + ~price ~fee ~fee_components ~executed_at ~slice_sequence let slice_open_marks market_slice = List.map @@ -867,63 +1594,194 @@ module Interactive = struct Account.position_quantity state.account instrument.Instrument.id in let candidate quantity = - let* fee = - fill_fee state.config.execution proposed.Execution.price quantity - in - let* fill = - Fill.create ~id:(fill_id state) ~order_id:order.Order.id - ~instrument_id:instrument.id ~quote_currency:instrument.quote_currency - ~side:order.request.side ~quantity ~price:proposed.price ~fee - ~executed_at:proposed.executed_at - ~slice_sequence:market_slice.Market_slice.slice_sequence - in - let* account = Account.apply_fill state.account fill in - let after_position = Account.position_quantity account instrument.id in - let* before_absolute = Scalar.Quantity.absolute before_position in - let* after_absolute = Scalar.Quantity.absolute after_position in - let* () = - if Scalar.Quantity.compare after_absolute before_absolute <= 0 then - Ok () - else Risk.check_position state.config.risk after_position - in - let* after = - Account.value account ~instruments ~marks - ~fx_rates:state.latest_fx_rates + let prepared = + let* fee_components, fee = + fill_fee state.config.execution instrument market_slice + proposed.Execution.liquidity proposed.price quantity + in + let* fill = + create_execution_fill state.config.execution ~id:(fill_id state) + ~order_id:order.Order.id ~instrument_id:instrument.id + ~quote_currency:instrument.quote_currency ~side:order.request.side + ~quantity ~price:proposed.price ~fee ~fee_components + ~executed_at:proposed.executed_at + ~slice_sequence:market_slice.Market_slice.slice_sequence + in + let* account = Account.apply_unsettled_fill state.account fill in + let after_position = Account.position_quantity account instrument.id in + let* after = + Account.value account ~instruments ~marks + ~fx_rates:state.latest_fx_rates + in + Ok (fee_components, fee, account, after_position, after) in - let* () = Risk.check_post_fill state.config.risk ~before ~after in - Ok fee + match prepared with + | Error message -> Error (`Invalid message) + | Ok (fee_components, fee, account, after_position, after) -> ( + let checked = + let* () = + let settlement = state.config.settlement in + match order.Order.request.side with + | Order.Buy -> ( + let available = + match settlement.Settlement.cash_buying_power with + | Settlement.Total_cash -> + Account.cash state.account instrument.quote_currency + | Settlement.Settled_cash -> + Account.settled_cash state.account + instrument.quote_currency + in + let available = + Option.value available ~default:Scalar.Money.zero + in + let available = + if Scalar.Money.compare available Scalar.Money.zero > 0 then + available + else Scalar.Money.zero + in + match + let* notional = + Scalar.Money.notional proposed.Execution.price quantity + in + Scalar.Money.add notional fee + with + | Error message -> Error (Risk.Invalid message) + | Ok cost -> + if Scalar.Money.compare cost available > 0 then + Error + (Risk.Limit + (Risk.Settlement_cash_buying_power + (instrument.quote_currency, available))) + else Ok ()) + | Order.Sell + when settlement.position_availability + = Settlement.Settled_positions + && Scalar.Quantity.is_positive before_position -> + let available = + Account.settled_position_quantity state.account + instrument.id + in + let available = + if Scalar.Quantity.is_positive available then available + else Scalar.Quantity.zero + in + if Scalar.Quantity.compare quantity available > 0 then + Error + (Risk.Limit + (Risk.Settlement_position_availability + (instrument.id, available))) + else Ok () + | Order.Sell -> Ok () + in + let* () = + Risk.check_post_fill_for state.config.risk + ~instrument_id:instrument.id ~before_position ~after_position + ~before ~after + in + Risk.check_reserved_fill state.config.risk ~account ~oms:state.oms + ~marks ~fx_rates:state.latest_fx_rates ~order + ~filled_quantity:quantity ~after + in + match checked with + | Ok () -> Ok (fee_components, fee) + | Error (Risk.Limit limit) -> Error (`Limit limit) + | Error (Risk.Invalid message) -> Error (`Invalid message)) in let lot_value = Scalar.Quantity.to_micros instrument.lot_size in + let* policy_order_limit = + match Risk.max_order_quantity_for state.config.risk instrument.id with + | Some value -> Ok value + | None -> Error "fill instrument has no risk policy" + in + let* borrow_constraint = + let policy = state.config.financing in + match order.Order.request.side with + | Order.Sell when not (Scalar.Quantity.is_positive before_position) -> + let available = + match + Id.Instrument.Map.find_opt instrument.id state.latest_borrow + with + | None -> Scalar.Quantity.zero + | Some observation when observation.Financing.recalled -> + Scalar.Quantity.zero + | Some observation -> observation.available_quantity + in + let* located = Scalar.Quantity.absolute before_position in + let remaining = + match Scalar.Quantity.subtract available located with + | Ok value -> value + | Error _ -> Scalar.Quantity.zero + in + let limit = + Risk.Instrument_borrow_availability (instrument.id, remaining) + in + Ok + (Some + ( remaining, + limit, + match policy.Financing.locate_policy with + | Financing.Reject_order -> true + | Financing.Clip_fill -> false )) + | Order.Sell | Order.Buy -> Ok None + in let quantity_limit = - Scalar.Quantity.minimum proposed.quantity - (Risk.max_order_quantity state.config.risk) + let risk_limit = + Scalar.Quantity.minimum proposed.quantity policy_order_limit + in + match borrow_constraint with + | None -> risk_limit + | Some (available, _, reject) -> + if reject && Scalar.Quantity.compare proposed.quantity available > 0 + then Scalar.Quantity.zero + else Scalar.Quantity.minimum risk_limit available in let requested_lots = Int64.div (Scalar.Quantity.to_micros quantity_limit) lot_value in - let allowed lots = - if Int64.equal lots 0L then true - else - let quantity = Scalar.Quantity.of_micros (Int64.mul lots lot_value) in - Result.is_ok (candidate quantity) - in let rec search low high = - if Int64.compare low high >= 0 then low + if Int64.compare low high >= 0 then Ok low else let difference = Int64.sub high low in let upper_half = Int64.add (Int64.div difference 2L) (Int64.rem difference 2L) in let middle = Int64.add low upper_half in - if allowed middle then search middle high - else search low (Int64.pred middle) + let quantity = Scalar.Quantity.of_micros (Int64.mul middle lot_value) in + match candidate quantity with + | Ok _ -> search middle high + | Error (`Limit _) -> search low (Int64.pred middle) + | Error (`Invalid message) -> Error message in - let lots = search 0L requested_lots in + let* lots = search 0L requested_lots in let quantity = Scalar.Quantity.of_micros (Int64.mul lots lot_value) in - if Scalar.Quantity.is_zero quantity then Ok (quantity, Scalar.Money.zero) - else candidate quantity |> Result.map (fun fee -> (quantity, fee)) + let clipped = Scalar.Quantity.compare quantity proposed.quantity < 0 in + let* limit = + if not clipped then Ok None + else if Int64.equal lots requested_lots then + match borrow_constraint with + | Some (available, limit, _) + when Scalar.Quantity.compare proposed.quantity available > 0 -> + Ok (Some limit) + | _ -> Ok (Some (Risk.Maximum_order_quantity policy_order_limit)) + else + let next_lots = Int64.succ lots in + let next_quantity = + Scalar.Quantity.of_micros (Int64.mul next_lots lot_value) + in + match candidate next_quantity with + | Error (`Limit limit) -> Ok (Some limit) + | Error (`Invalid message) -> Error message + | Ok _ -> Error "fill clipping search produced a nonmaximal quantity" + in + if Scalar.Quantity.is_zero quantity then + Ok (quantity, [], Scalar.Money.zero, limit) + else + match candidate quantity with + | Ok (fee_components, fee) -> Ok (quantity, fee_components, fee, limit) + | Error (`Invalid message) -> Error message + | Error (`Limit _) -> Error "permitted fill violates its limiting policy" - let apply_fill reduction market_slice proposed quantity fee = + let apply_fill reduction market_slice proposed quantity fee_components fee = match Oms.find reduction.state.oms proposed.Execution.order_id with | None -> Error "execution proposal refers to an unknown order" | Some order -> ( @@ -933,17 +1791,34 @@ module Interactive = struct with | None -> Error "execution order refers to an unknown instrument" | Some instrument -> ( + let* reduction = + match proposed.Execution.price_attribution with + | None -> Ok reduction + | Some attribution -> + let* reduction, price_event_id = + emit_with_id reduction + (Audit.Execution_price_selected + { + order_id = order.id; + instrument_id = order.request.instrument_id; + side = order.request.side; + attribution; + }) + in + Ok (with_causes reduction [ price_event_id ]) + in let id = fill_id reduction.state in match increment_fill_number reduction.state with | Error _ as error -> error | Ok state -> ( let reduction = { reduction with state } in match - Fill.create ~id ~order_id:order.id + create_execution_fill reduction.state.config.execution ~id + ~order_id:order.id ~instrument_id:order.request.instrument_id ~quote_currency:instrument.Instrument.quote_currency ~side:order.request.side ~quantity ~price:proposed.price - ~fee ~executed_at:proposed.executed_at + ~fee ~fee_components ~executed_at:proposed.executed_at ~slice_sequence:market_slice.Market_slice.slice_sequence with | Error _ as error -> error @@ -954,7 +1829,8 @@ module Interactive = struct Error "newly allocated fill ID was duplicated" | Ok (oms, Oms.Applied order) -> ( match - Account.apply_fill reduction.state.account fill + Account.apply_unsettled_fill reduction.state.account + fill with | Error _ as error -> error | Ok account -> ( @@ -965,6 +1841,25 @@ module Interactive = struct with | Error _ as error -> error | Ok (reduction, event_id) -> + let* reduction = + let* instruction = + Settlement.instruction + reduction.state.config.settlement fill + in + let state = + { + reduction.state with + settlement_instructions = + reduction.state.settlement_instructions + @ [ instruction ]; + } + in + emit + (with_causes { reduction with state } + [ event_id ]) + (Audit.Settlement_instruction_created + instruction) + in let* fill_pending = notification reduction ~causation_ids:[ event_id ] @@ -1003,44 +1898,58 @@ module Interactive = struct | Some value -> Ok value | None -> Error "execution order refers to an unknown instrument" in - let* permitted_quantity, fee = + let* permitted_quantity, fee_components, fee, limit = permitted_fill reduction.state market_slice order proposed instrument in - let clipped = - Scalar.Quantity.compare permitted_quantity proposed.quantity < 0 + let permitted_quantity = + if + Order.is_fok order + && Scalar.Quantity.compare permitted_quantity + (Order.remaining_quantity order) + < 0 + then Scalar.Quantity.zero + else permitted_quantity in let* reduction = - if clipped then - emit reduction - (Audit.Margin_limited - { - order_id = order.id; - instrument_id = order.request.instrument_id; - requested_quantity = proposed.quantity; - permitted_quantity; - price = proposed.price; - }) - else Ok reduction + match limit with + | None -> Ok reduction + | Some limit -> + emit reduction + (Audit.Fill_clipped + { + order_id = order.id; + instrument_id = order.request.instrument_id; + proposed_quantity = proposed.quantity; + permitted_quantity; + price = proposed.price; + limit; + }) in if Scalar.Quantity.is_zero permitted_quantity then Ok (reduction, permitted_quantity) else - apply_fill reduction market_slice proposed permitted_quantity fee + apply_fill reduction market_slice proposed permitted_quantity + fee_components fee |> Result.map (fun reduction -> (reduction, permitted_quantity)) - let cancel_market_remainders reduction order_ids = + let cancel_immediate_remainders reduction order_ids = let causation_ids = reduction.causation_ids in let rec cancel reduction = function | [] -> Ok (with_causes reduction causation_ids) | order_id :: remaining -> ( match Oms.find reduction.state.oms order_id with - | None -> Error "market IOC order disappeared during matching" + | None -> Error "immediate order disappeared during matching" | Some order -> ( + let reason = + if Order.is_fok order then Audit.Fill_or_kill + else if Order.is_market order then Audit.Market_ioc + else Audit.Immediate_or_cancel + in let result = if Order.is_active order then cancel_order (with_causes reduction causation_ids) - ~reason:Audit.Market_ioc order_id + ~reason order_id else Ok reduction in match result with @@ -1049,6 +1958,45 @@ module Interactive = struct in cancel reduction order_ids + let cancel_expired_gtd reduction (market_slice : Market_slice.t) = + Oms.active_orders reduction.state.oms + |> List.filter_map (fun order -> + match order.Order.request.time_in_force with + | Order.Gtd expires_at + when Ptime.compare expires_at market_slice.end_at <= 0 -> + Some order.id + | Order.Gtc | Order.Ioc | Order.Fok | Order.Day _ | Order.Gtd _ -> None) + |> cancel_orders reduction ~reason:Audit.Gtd_expired + + let day_session_closed state (market_slice : Market_slice.t) order = + match order.Order.request.time_in_force with + | Order.Day { calendar_id; _ } -> ( + match + List.find_opt + (fun calendar -> + Id.Venue_calendar.equal calendar.Venue_calendar.id calendar_id) + state.config.venue_calendars + with + | None -> false + | Some calendar -> + List.exists + (fun (session : Venue_calendar.session) -> + match List.rev session.phases with + | [] -> false + | phase :: _ -> + Ptime.compare phase.closes_at order.Order.created_at > 0 + && Ptime.compare phase.closes_at market_slice.end_at <= 0) + calendar.sessions) + | Order.Gtc | Order.Ioc | Order.Fok | Order.Gtd _ -> false + + let cancel_expired_day reduction market_slice = + Oms.active_orders reduction.state.oms + |> List.filter_map (fun order -> + if day_session_closed reduction.state market_slice order then + Some order.Order.id + else None) + |> cancel_orders reduction ~reason:Audit.Day_expired + let audit_valuation state = let* account = value state in let* margin = Risk.margin_snapshot state.config.risk account in @@ -1100,10 +2048,12 @@ module Interactive = struct match Risk.instrument state.config.risk instrument_id with | None -> Error "target refers to an unknown instrument" | Some instrument -> ( - let bounded = - Scalar.Quantity.minimum delta - (Risk.max_order_quantity state.config.risk) + let order_limit = + Risk.max_order_quantity_for state.config.risk instrument_id + |> Option.value + ~default:(Risk.max_order_quantity state.config.risk) in + let bounded = Scalar.Quantity.minimum delta order_limit in match Scalar.Quantity.round_toward_zero_to_multiple bounded ~multiple:instrument.lot_size @@ -1113,7 +2063,8 @@ module Interactive = struct Error "target order limit cannot cover one instrument lot" | Ok quantity -> Order.request ~instrument_id ~side ~quantity - ~kind:Order.Market ~origin:Order.Target_rebalance + ~kind:Order.Market ~time_in_force:Order.Ioc + ~origin:Order.Target_rebalance |> Result.map Option.some)) let reconcile_targets reduction = @@ -1163,10 +2114,13 @@ module Interactive = struct if Scalar.Quantity.is_zero quantity || already_working then Ok reduction else let* absolute = Scalar.Quantity.absolute quantity in - let bounded = - Scalar.Quantity.minimum absolute - (Risk.max_order_quantity reduction.state.config.risk) + let order_limit = + Risk.max_order_quantity_for reduction.state.config.risk + instrument.Instrument.id + |> Option.value + ~default:(Risk.max_order_quantity reduction.state.config.risk) in + let bounded = Scalar.Quantity.minimum absolute order_limit in let* quantity = Scalar.Quantity.round_toward_zero_to_multiple bounded ~multiple:instrument.lot_size @@ -1184,7 +2138,8 @@ module Interactive = struct in let* request = Order.request ~instrument_id:instrument.id ~side ~quantity - ~kind:Order.Market ~origin:Order.Margin_liquidation + ~kind:Order.Market ~time_in_force:Order.Ioc + ~origin:Order.Margin_liquidation in submit_order (with_causes reduction causes) request) (Ok reduction) @@ -1233,112 +2188,50 @@ module Interactive = struct (Option.to_list reduction.slice_event_id) else Ok reduction - type phase = - | Match_slice of Market_slice.t * Execution.cursor - | Reconcile_targets - | Finish_slice - - type progress = - | Awaiting_strategy of { - reduction : reduction; - phase : phase; - causation_ids : Id.Event.t list; - context : Strategy.context; - event : Strategy.event; - } - | Slice_completed of t * Audit.t list - - let rec continue phase reduction = - let* drained = drain reduction in - match drained with - | Strategy_requested { reduction; causation_ids; context; event } -> - Ok - (Awaiting_strategy { reduction; phase; causation_ids; context; event }) - | Drained reduction -> ( - match phase with - | Match_slice (market_slice, cursor) -> ( - match Execution.next cursor ~oms:reduction.state.oms with - | Error _ as error -> error - | Ok (Execution.Proposed (proposed, advance)) -> - let* reduction, applied_quantity = - apply_proposed_fill reduction market_slice proposed - in - let* cursor = advance applied_quantity in - continue (Match_slice (market_slice, cursor)) reduction - | Ok (Execution.Finished market_ioc_orders) -> - let* slice_event_id = - match reduction.slice_event_id with - | Some value -> Ok value - | None -> Error "matching slice has no audit event" - in - let reduction = with_causes reduction [ slice_event_id ] in - let* reduction = - cancel_market_remainders reduction market_ioc_orders - in - let* pending = - notification reduction ~causation_ids:[ slice_event_id ] - (Strategy.Market_slice_closed market_slice) - in - continue Reconcile_targets (enqueue reduction [ pending ])) - | Reconcile_targets -> - let* reduction = reconcile_targets reduction in - continue Finish_slice reduction - | Finish_slice -> - let* reduction = assess_margin reduction in - if reduction.pending = [] then - let* reduction = valuation reduction in - Ok - (Slice_completed (reduction.state, List.rev reduction.audits_rev)) - else continue Finish_slice reduction) - - let strategy_request = function - | Awaiting_strategy { context; event; _ } -> Some (context, event) - | Slice_completed _ -> None - - let slice_result = function - | Awaiting_strategy _ -> None - | Slice_completed (state, audits) -> Some (state, audits) - - let resume progress intents = - match progress with - | Slice_completed _ -> - Error "completed slice cannot accept strategy intents" - | Awaiting_strategy { reduction; phase; causation_ids; _ } -> - let actions = - List.map (fun intent -> Act (causation_ids, intent)) intents - in - continue phase (prepend reduction actions) - - let process_slice state market_slice = - if state.completed then - Error "completed engine cannot process another market slice" - else - let* () = validate_slice state market_slice in - let state = - let applied_action_ids = - List.fold_left - (fun ids action -> - Id.Corporate_action.Set.add action.Corporate_action.id ids) - state.applied_action_ids market_slice.corporate_actions - in - let latest_bars = - List.fold_left - (fun bars bar -> - Id.Instrument.Map.add bar.Bar.instrument_id bar bars) - state.latest_bars market_slice.bars - in - { - state with - last_slice_sequence = Some market_slice.slice_sequence; - last_slice_end = Some market_slice.end_at; - last_received_at = Some market_slice.received_at; - latest_fx_rates = - List.map - (fun mark -> (mark.Market_slice.currency, mark.Market_slice.rate)) - market_slice.fx_rates; - latest_bars; - applied_action_ids; - } + module Validation_phase = struct + let run state market_slice = + if state.completed then + Error "completed engine cannot process another market slice" + else validate_slice state market_slice + end + + module Initialize_phase = struct + let run state market_slice = + let applied_action_ids = + List.fold_left + (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 + (fun bars bar -> Id.Instrument.Map.add bar.Bar.instrument_id bar bars) + state.latest_bars market_slice.bars + in + let latest_marks = + List.fold_left + (fun marks bar -> + Id.Instrument.Map.add bar.Bar.instrument_id bar.close_price marks) + state.latest_marks market_slice.bars + in + let latest_borrow = + List.fold_left + (fun observations (observation : Financing.borrow_observation) -> + Id.Instrument.Map.add observation.instrument_id observation + observations) + state.latest_borrow market_slice.borrow_observations + in + let latest_cash_rates = + List.fold_left + (fun observations (observation : Financing.cash_rate_observation) -> + Currency_map.add observation.currency observation observations) + state.latest_cash_rates market_slice.cash_rate_observations in let reduction = { @@ -1348,33 +2241,172 @@ module Interactive = struct slice_event_id = None; causation_ids = []; audits_rev = []; - pending = []; + pending = Pending_queue.empty; processed = 0; } in let* reduction = ensure_started reduction in + let state = + { + reduction.state with + last_slice_sequence = Some market_slice.slice_sequence; + last_slice_end = Some market_slice.end_at; + last_received_at = Some market_slice.received_at; + latest_fx_rates = + List.map + (fun mark -> (mark.Market_slice.currency, mark.Market_slice.rate)) + market_slice.fx_rates; + latest_bars; + latest_marks; + latest_borrow; + latest_cash_rates; + applied_action_ids; + } + in + let reduction = { reduction with state } in let* reduction, slice_event_id = emit_with_id (with_causes reduction []) (Audit.Market_slice_received market_slice) in - let reduction = + Ok { reduction with slice_event_id = Some slice_event_id; causation_ids = [ slice_event_id ]; } - in + end + + module Actions_phase = struct + let run market_slice reduction = + let* reduction = cancel_expired_gtd reduction market_slice in + let* reduction = process_settlements reduction market_slice in let* reduction = - apply_corporate_actions reduction market_slice.corporate_actions + apply_corporate_actions reduction + market_slice.Market_slice.corporate_actions in - let* reduction = apply_borrow_fees reduction market_slice in - let* cursor = - Execution_model.start_slice reduction.state.config.execution_model - reduction.state.config.execution - ~instruments:(configured_instruments reduction.state) - ~oms:reduction.state.oms market_slice + apply_lifecycle_events reduction market_slice.lifecycle_events + end + + module Borrow_phase = struct + let run market_slice reduction = apply_financing reduction market_slice + end + + module Notifications_phase = struct + type request = { + reduction : reduction; + causation_ids : Id.Event.t list; + context : Strategy.context; + event : Strategy.event; + } + + type outcome = Drained of reduction | Awaiting of request + + let run reduction = + match drain reduction with + | Error _ as error -> error + | Ok result -> ( + match (result : drain_result) with + | Drained reduction -> Ok (Drained reduction) + | Strategy_requested { reduction; causation_ids; context; event } -> + Ok (Awaiting { reduction; causation_ids; context; event })) + + let has_pending reduction = not (Pending_queue.is_empty reduction.pending) + let payload request = (request.context, request.event) + + let resume request intents = + let actions = + List.map (fun intent -> Act (request.causation_ids, intent)) intents in - continue (Match_slice (market_slice, cursor)) reduction + prepend request.reduction actions + end + + module Matching_phase = struct + type outcome = + | Continue of reduction * Execution.cursor + | Complete of reduction + + let start market_slice reduction = + Execution_model.start_slice reduction.state.config.execution_model + reduction.state.config.execution + ~instruments:(configured_instruments reduction.state) + ~oms:reduction.state.oms market_slice + + let run market_slice cursor reduction = + match Execution.next cursor ~oms:reduction.state.oms with + | Error _ as error -> error + | Ok + (Execution.Triggered + (order_id, triggered_at, triggered_slice_sequence, cursor)) -> + let* reduction = + trigger_order reduction order_id ~triggered_at + ~triggered_slice_sequence + in + Ok (Continue (reduction, cursor)) + | Ok (Execution.Proposed (proposed, advance)) -> + let* reduction, applied_quantity = + apply_proposed_fill reduction market_slice proposed + in + let* cursor = advance applied_quantity in + Ok (Continue (reduction, cursor)) + | Ok (Execution.Finished market_ioc_orders) -> + let* slice_event_id = + match reduction.slice_event_id with + | Some value -> Ok value + | None -> Error "matching slice has no audit event" + in + let reduction = with_causes reduction [ slice_event_id ] in + let* reduction = + cancel_immediate_remainders reduction market_ioc_orders + in + let* reduction = cancel_expired_day reduction market_slice in + let* pending = + notification reduction ~causation_ids:[ slice_event_id ] + (Strategy.Market_slice_closed market_slice) + in + Ok (Complete (enqueue reduction [ pending ])) + end + + module Targets_phase = struct + let run = reconcile_targets + end + + module Margin_phase = struct + let run = assess_margin + end + + module Valuation_phase = struct + let run reduction = + let* reduction = valuation reduction in + Ok (reduction.state, List.rev reduction.audits_rev) + end + + module Phase_machine = Reducer_phases.Make (struct + type nonrec state = t + type nonrec reduction = reduction + type market_slice = Market_slice.t + type cursor = Execution.cursor + type audit = Audit.t + type context = Strategy.context + type event = Strategy.event + type intent = Strategy.intent + + module Validation = Validation_phase + module Initialize = Initialize_phase + module Actions = Actions_phase + module Borrow = Borrow_phase + module Notifications = Notifications_phase + module Matching = Matching_phase + module Targets = Targets_phase + module Margin = Margin_phase + module Valuation = Valuation_phase + end) + + type progress = Phase_machine.progress + + let strategy_request = Phase_machine.strategy_request + let slice_result = Phase_machine.slice_result + let resume = Phase_machine.resume + let process_slice = Phase_machine.process_slice let order_counts orders = List.fold_left @@ -1416,7 +2448,7 @@ module Interactive = struct slice_event_id = None; causation_ids; audits_rev = []; - pending = []; + pending = Pending_queue.empty; processed = 0; } in @@ -1446,8 +2478,9 @@ module Make (Strategy_impl : Strategy.S) = struct type t = { engine : Interactive.t; strategy_state : Strategy_impl.state } - let create ~run_id ~scenario_sha256 ~config ~initial_cash ~strategy_state = - Interactive.create ~run_id ~scenario_sha256 ~config ~initial_cash + let create ~run_id ~scenario_sha256 ~config ~initial_portfolio ~strategy_state + = + Interactive.create ~run_id ~scenario_sha256 ~config ~initial_portfolio |> Result.map (fun engine -> { engine; strategy_state }) let account state = Interactive.account state.engine diff --git a/lib/engine.mli b/lib/engine.mli index f6482c2..4014293 100644 --- a/lib/engine.mli +++ b/lib/engine.mli @@ -3,9 +3,13 @@ type config val config : + 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 @@ -17,7 +21,7 @@ module Interactive : sig run_id:Id.Run.t -> scenario_sha256:string -> config:config -> - initial_cash:(string * Scalar.Money.t) list -> + initial_portfolio:Initial_portfolio.t -> (t, string) result val account : t -> Account.t @@ -37,7 +41,7 @@ module Make (Strategy_impl : Strategy.S) : sig run_id:Id.Run.t -> scenario_sha256:string -> config:config -> - initial_cash:(string * Scalar.Money.t) list -> + initial_portfolio:Initial_portfolio.t -> strategy_state:Strategy_impl.state -> (t, string) result diff --git a/lib/execution.ml b/lib/execution.ml index 6cf73c6..49c5cdf 100644 --- a/lib/execution.ml +++ b/lib/execution.ml @@ -1,15 +1,39 @@ -type t = { participation_bps : int; fixed_fee : Scalar.Money.t; fee_bps : int } +type missing_volume_policy = Reject_missing_volume | Zero_impact + +type cost_model = { + half_spread_bps : int; + impact_coefficient_bps : int; + missing_volume_policy : missing_volume_policy; +} + +type t = { + participation_bps : int; + fee_schedules : Fee_schedule.t Id.Instrument.Map.t; + cost_model : cost_model option; + book_depth_limit : int option; +} + +type price_attribution = { + reference_price : Scalar.Price.t; + spread_adjustment : Scalar.Money.t; + impact_adjustment : Scalar.Money.t; + final_price : Scalar.Price.t; +} type proposed_fill = { order_id : Id.Order.t; quantity : Scalar.Quantity.t; price : Scalar.Price.t; fee : Scalar.Money.t; + fee_components : Fee_schedule.calculated_component list; + liquidity : Fee_schedule.liquidity; executed_at : Ptime.t; + price_attribution : price_attribution option; } type match_result = { fills : proposed_fill list; + triggers : (Id.Order.t * Ptime.t * int64) list; market_ioc_orders : Id.Order.t list; } @@ -19,40 +43,241 @@ type cursor = Cursor of (Oms.t -> (step, string) result) and step = | Finished of Id.Order.t list + | Triggered of Id.Order.t * Ptime.t * int64 * cursor | Proposed of proposed_fill * (Scalar.Quantity.t -> (cursor, string) result) +let ( let* ) result function_ = + match result with Ok value -> function_ value | Error _ as error -> error + let cursor next = Cursor (fun oms -> next ~oms) -let create ~participation_bps ~fixed_fee ~fee_bps = +let create ~participation_bps ~fee_schedules = if participation_bps < 0 || participation_bps > 10_000 then Error "participation basis points must be between 0 and 10000" - else if Scalar.Money.compare fixed_fee Scalar.Money.zero < 0 then - Error "fixed fee must be nonnegative" - else if fee_bps < 0 || fee_bps > 10_000 then - Error "fee basis points must be between 0 and 10000" - else Ok { participation_bps; fixed_fee; fee_bps } + else + let add result schedule = + let ( let* ) result function_ = + match result with + | Ok value -> function_ value + | Error _ as error -> error + in + let* schedules = result in + let instrument_id = Fee_schedule.instrument_id schedule in + if Id.Instrument.Map.mem instrument_id schedules then + Error "fee schedules must have unique instrument IDs" + else Ok (Id.Instrument.Map.add instrument_id schedule schedules) + in + Result.map + (fun schedules -> + { + participation_bps; + fee_schedules = schedules; + cost_model = None; + book_depth_limit = None; + }) + (List.fold_left add (Ok Id.Instrument.Map.empty) fee_schedules) + +let create_conservative ~participation_bps ~fee_schedules ~half_spread_bps + ~impact_coefficient_bps ~missing_volume_policy = + if half_spread_bps < 0 || half_spread_bps > 10_000 then + Error "half-spread basis points must be between 0 and 10000" + else if impact_coefficient_bps < 0 || impact_coefficient_bps > 10_000 then + Error "impact coefficient basis points must be between 0 and 10000" + else + Result.map + (fun state -> + { + state with + cost_model = + Some + { half_spread_bps; impact_coefficient_bps; missing_volume_policy }; + }) + (create ~participation_bps ~fee_schedules) + +let create_order_book ~participation_bps ~fee_schedules ~max_depth_levels = + if max_depth_levels <= 0 || max_depth_levels > 1024 then + Error "order-book depth limit must be between 1 and 1024" + else + Result.map + (fun state -> { state with book_depth_limit = Some max_depth_levels }) + (create ~participation_bps ~fee_schedules) let participation_bps state = state.participation_bps -let fixed_fee state = state.fixed_fee -let fee_bps state = state.fee_bps +let book_depth_limit state = state.book_depth_limit + +let fee_schedules state = + Id.Instrument.Map.bindings state.fee_schedules |> List.map snd + +let cost_model state = state.cost_model + +let calculate_fee state ~instrument ~notional ~quantity ~liquidity ~fx_rates = + match + Id.Instrument.Map.find_opt instrument.Instrument.id state.fee_schedules + with + | None -> Error "execution instrument has no configured fee schedule" + | Some schedule -> + Fee_schedule.calculate schedule ~quote_currency:instrument.quote_currency + ~notional ~quantity ~liquidity ~fx_rates -let execution_price order market_slice bar = - match order.Order.request.kind with - | Order.Market -> Some (bar.Bar.open_price, market_slice.Market_slice.start_at) - | Order.Limit limit -> ( +type limit_fill_policy = Optimistic_touch | Next_open_only | Adverse_touch + +let checked_price_micros value = + if Z.fits_int64 value then Scalar.Price.of_micros (Z.to_int64 value) + else Error "execution price overflow" + +let adverse_reference side limit tick = + let limit = Z.of_int64 (Scalar.Price.to_micros limit) in + let tick = Z.of_int64 (Scalar.Price.to_micros tick) in + checked_price_micros + (match side with Order.Buy -> Z.sub limit tick | Sell -> Z.add limit tick) + +let execution_reference policy instrument order market_slice bar = + match Order.effective_kind order with + | None -> None + | Some Order.Market -> + Some + ( bar.Bar.open_price, + market_slice.Market_slice.start_at, + Fee_schedule.Taker ) + | Some (Order.Limit limit) -> ( match order.request.side with - | Order.Buy -> + | Order.Buy -> ( if Scalar.Price.compare bar.open_price limit <= 0 then - Some (bar.open_price, market_slice.start_at) - else if Scalar.Price.compare bar.low_price limit <= 0 then - Some (limit, market_slice.end_at) - else None - | Order.Sell -> + Some (bar.open_price, market_slice.start_at, Fee_schedule.Taker) + else + match policy with + | Optimistic_touch -> + if Scalar.Price.compare bar.low_price limit <= 0 then + Some (limit, market_slice.end_at, Fee_schedule.Maker) + else None + | Next_open_only -> None + | Adverse_touch -> ( + match + adverse_reference Order.Buy limit + instrument.Instrument.tick_size + with + | Error _ -> None + | Ok reference -> + if Scalar.Price.compare bar.low_price reference <= 0 then + Some (reference, market_slice.end_at, Fee_schedule.Maker) + else None)) + | Order.Sell -> ( if Scalar.Price.compare bar.open_price limit >= 0 then - Some (bar.open_price, market_slice.start_at) - else if Scalar.Price.compare bar.high_price limit >= 0 then - Some (limit, market_slice.end_at) - else None) + Some (bar.open_price, market_slice.start_at, Fee_schedule.Taker) + else + match policy with + | Optimistic_touch -> + if Scalar.Price.compare bar.high_price limit >= 0 then + Some (limit, market_slice.end_at, Fee_schedule.Maker) + else None + | Next_open_only -> None + | Adverse_touch -> ( + match + adverse_reference Order.Sell limit instrument.tick_size + with + | Error _ -> None + | Ok reference -> + if Scalar.Price.compare bar.high_price reference >= 0 then + Some (reference, market_slice.end_at, Fee_schedule.Maker) + else None))) + | Some (Order.Stop _ | Order.Stop_limit _) -> None + +let ceil_div numerator denominator = + if Z.equal numerator Z.zero then Z.zero + else Z.div (Z.add numerator (Z.pred denominator)) denominator + +let round_up_to_tick value tick = Z.mul (ceil_div value tick) tick + +let price_adjustment reference bps = + ceil_div + (Z.mul (Z.of_int64 (Scalar.Price.to_micros reference)) (Z.of_int bps)) + (Z.of_int 10_000) + +let impact_adjustment reference coefficient quantity volume = + ceil_div + (Z.mul + (Z.mul + (Z.of_int64 (Scalar.Price.to_micros reference)) + (Z.of_int coefficient)) + (Z.of_int64 (Scalar.Quantity.to_micros quantity))) + (Z.mul (Z.of_int 10_000) (Z.of_int64 (Scalar.Quantity.to_micros volume))) + +let apply_cost_model state instrument order bar quantity reference = + match state.cost_model with + | None -> Ok (Some (reference, None)) + | Some model -> + let tick = + Z.of_int64 (Scalar.Price.to_micros instrument.Instrument.tick_size) + in + let spread = + price_adjustment reference model.half_spread_bps |> fun value -> + round_up_to_tick value tick + in + let* impact = + if model.impact_coefficient_bps = 0 then Ok Z.zero + else + match bar.Bar.volume with + | Some volume when not (Scalar.Quantity.is_zero volume) -> + Ok + ( impact_adjustment reference model.impact_coefficient_bps + quantity volume + |> fun value -> round_up_to_tick value tick ) + | Some _ | None -> ( + match model.missing_volume_policy with + | Reject_missing_volume -> + Error "impact model requires completed-bar volume" + | Zero_impact -> Ok Z.zero) + in + let adjustment = Z.add spread impact in + let reference_micros = Z.of_int64 (Scalar.Price.to_micros reference) in + let final_micros = + match order.Order.request.side with + | Buy -> Z.add reference_micros adjustment + | Sell -> Z.sub reference_micros adjustment + in + let* final_price = checked_price_micros final_micros in + let respects_limit = + match Order.effective_kind order with + | Some (Order.Limit limit) -> ( + match order.request.side with + | Buy -> Scalar.Price.compare final_price limit <= 0 + | Sell -> Scalar.Price.compare final_price limit >= 0) + | Some (Market | Stop _ | Stop_limit _) | None -> true + in + if not respects_limit then Ok None + else if not (Z.fits_int64 spread && Z.fits_int64 impact) then + Error "execution price adjustment overflow" + else + Ok + (Some + ( final_price, + Some + { + reference_price = reference; + spread_adjustment = + Scalar.Money.of_micros (Z.to_int64 spread); + impact_adjustment = + Scalar.Money.of_micros (Z.to_int64 impact); + final_price; + } )) + +let stop_trigger order market_slice bar = + match (order.Order.request.kind, order.request.side) with + | Order.Stop trigger_price, Order.Buy + | Order.Stop_limit { trigger_price; _ }, Order.Buy -> + if Scalar.Price.compare bar.Bar.open_price trigger_price >= 0 then + Some market_slice.Market_slice.start_at + else if Scalar.Price.compare bar.high_price trigger_price >= 0 then + Some market_slice.end_at + else None + | Order.Stop trigger_price, Order.Sell + | Order.Stop_limit { trigger_price; _ }, Order.Sell -> + if Scalar.Price.compare bar.Bar.open_price trigger_price <= 0 then + Some market_slice.Market_slice.start_at + else if Scalar.Price.compare bar.low_price trigger_price <= 0 then + Some market_slice.end_at + else None + | (Order.Market | Order.Limit _), _ -> None let available_quantity capacity remaining = match capacity with @@ -90,7 +315,11 @@ let validate_bar_prices instrument bar = else Ok () let compare_execution_order left right = - let origin_rank = function Order.Margin_liquidation -> 0 | _ -> 1 in + let origin_rank = function + | Order.Margin_liquidation -> 0 + | Order.Borrow_recall -> 1 + | Order.Direct | Order.Target_rebalance -> 2 + in let origin = Int.compare (origin_rank left.Order.request.origin) @@ -110,7 +339,8 @@ let compare_execution_order left right = in if sequence <> 0 then sequence else Id.Order.compare left.id right.id -let start_slice state ~instruments ~oms (market_slice : Market_slice.t) = +let start_slice_with_policy policy state ~instruments ~oms + (market_slice : Market_slice.t) = let ( let* ) result function_ = match result with Ok value -> function_ value | Error _ as error -> error in @@ -138,12 +368,21 @@ let start_slice state ~instruments ~oms (market_slice : Market_slice.t) = Int64.compare order.Order.eligible_after_slice_sequence market_slice.slice_sequence < 0 - && Ptime.compare order.created_at market_slice.start_at <= 0) + && Ptime.compare order.created_at market_slice.start_at <= 0 + && + match order.trigger_state with + | Some (Order.Triggered { triggered_slice_sequence; _ }) -> + Int64.compare triggered_slice_sequence market_slice.slice_sequence + < 0 + | Some Order.Dormant | None -> true) |> List.sort compare_execution_order in let market_ioc_orders = List.filter_map - (fun order -> if Order.is_market order then Some order.Order.id else None) + (fun order -> + if Order.is_ioc order && not (Order.is_dormant_stop order) then + Some order.Order.id + else None) eligible in let eligible_order_ids = List.map (fun order -> order.Order.id) eligible in @@ -169,53 +408,1041 @@ let start_slice state ~instruments ~oms (market_slice : Market_slice.t) = | None, _, _ | _, None, _ | _, _, None -> Error "eligible order has no configured bar in the market slice" | Some bar, Some capacity, Some instrument -> ( - match execution_price order market_slice bar with - | None -> - let (Cursor next) = make_cursor capacities remaining in - next current_oms - | Some (price, executed_at) -> - let quantity = - available_quantity capacity (Order.remaining_quantity order) - in - if Scalar.Quantity.is_zero quantity then + if Order.is_dormant_stop order then + match stop_trigger order market_slice bar with + | None -> let (Cursor next) = make_cursor capacities remaining in next current_oms - else - let* notional = Scalar.Money.notional price quantity in - let* fee = - Scalar.Money.fee ~fixed:state.fixed_fee ~bps:state.fee_bps - ~notional - in - let proposed = - { order_id = order.id; quantity; price; fee; executed_at } - in - let continue applied_quantity = - if - Scalar.Quantity.compare applied_quantity Scalar.Quantity.zero - < 0 - then Error "applied fill quantity must be nonnegative" - else if Scalar.Quantity.compare applied_quantity quantity > 0 - then - Error "applied fill quantity exceeds the execution proposal" - else if - not - (Scalar.Quantity.is_multiple applied_quantity - ~lot:instrument.Instrument.lot_size) - then - Error - "applied fill quantity is not aligned to the instrument \ - lot size" - else - let* capacity = consume capacity applied_quantity in - let capacities = - Id.Instrument.Map.add instrument_id capacity capacities - in - Ok (make_cursor capacities remaining) + | Some triggered_at -> + Ok + (Triggered + ( order.id, + triggered_at, + market_slice.slice_sequence, + make_cursor capacities remaining )) + else + match + execution_reference policy instrument order market_slice bar + with + | None -> + let (Cursor next) = make_cursor capacities remaining in + next current_oms + | Some (reference_price, executed_at, liquidity) -> ( + let quantity = + available_quantity capacity (Order.remaining_quantity order) in - Ok (Proposed (proposed, continue))) + if + Scalar.Quantity.is_zero quantity + || Order.is_fok order + && Scalar.Quantity.compare quantity + (Order.remaining_quantity order) + < 0 + then + let (Cursor next) = make_cursor capacities remaining in + next current_oms + else + let* priced = + apply_cost_model state instrument order bar quantity + reference_price + in + match priced with + | None -> + let (Cursor next) = make_cursor capacities remaining in + next current_oms + | Some (price, price_attribution) -> + let* notional = Scalar.Money.notional price quantity in + let* fee_components, fee = + calculate_fee state ~instrument ~notional ~quantity + ~liquidity + ~fx_rates: + (List.map + (fun mark -> + (mark.Market_slice.currency, mark.rate)) + market_slice.fx_rates) + in + let proposed = + { + order_id = order.id; + quantity; + price; + fee; + fee_components; + liquidity; + executed_at; + price_attribution; + } + in + let continue applied_quantity = + if + Scalar.Quantity.compare applied_quantity + Scalar.Quantity.zero + < 0 + then Error "applied fill quantity must be nonnegative" + else if + Scalar.Quantity.compare applied_quantity quantity > 0 + then + Error + "applied fill quantity exceeds the execution proposal" + else if + not + (Scalar.Quantity.is_multiple applied_quantity + ~lot:instrument.Instrument.lot_size) + then + Error + "applied fill quantity is not aligned to the \ + instrument lot size" + else + let* capacity = consume capacity applied_quantity in + let capacities = + Id.Instrument.Map.add instrument_id capacity + capacities + in + Ok (make_cursor capacities remaining) + in + Ok (Proposed (proposed, continue)))) in Ok (make_cursor capacities eligible_order_ids) +let start_slice state = start_slice_with_policy Optimistic_touch state +let start_slice_next_open state = start_slice_with_policy Next_open_only state + +let start_slice_adverse_touch state = + start_slice_with_policy Adverse_touch state + +type observable_liquidity = + | Quote_liquidity of { bid : Scalar.Quantity.t; ask : Scalar.Quantity.t } + | Trade_liquidity of Scalar.Quantity.t + +let event_capacity state instrument quantity = + let* capacity = + Scalar.Quantity.bps_floor quantity ~bps:state.participation_bps + in + Scalar.Quantity.round_toward_zero_to_multiple capacity + ~multiple:instrument.Instrument.lot_size + +let validate_market_event instrument (event : Market_event.t) = + let tick = instrument.Instrument.tick_size in + let aligned = function + | Market_event.Quote { bid_price; ask_price; _ } -> + Scalar.Price.is_multiple bid_price ~tick + && Scalar.Price.is_multiple ask_price ~tick + | Trade { price; _ } -> Scalar.Price.is_multiple price ~tick + in + if not (Id.Instrument.equal instrument.id event.instrument_id) then + Error "execution instrument differs from the market event instrument" + else if not (aligned event.kind) then + Error "market event price is not aligned to the instrument tick size" + else Ok () + +let event_trigger order (event : Market_event.t) = + let observed_price = + match (event.kind, order.Order.request.side) with + | Market_event.Quote { ask_price; _ }, Order.Buy -> ask_price + | Quote { bid_price; _ }, Sell -> bid_price + | Trade { price; _ }, _ -> price + in + match (order.request.kind, order.request.side) with + | Order.Stop trigger, Buy | Stop_limit { trigger_price = trigger; _ }, Buy -> + Scalar.Price.compare observed_price trigger >= 0 + | Order.Stop trigger, Sell | Stop_limit { trigger_price = trigger; _ }, Sell + -> + Scalar.Price.compare observed_price trigger <= 0 + | (Market | Limit _), _ -> false + +let event_opportunity order (event : Market_event.t) liquidity = + match + (event.kind, liquidity, Order.effective_kind order, order.request.side) + with + | Quote { ask_price; _ }, Quote_liquidity { ask; _ }, Some Market, Buy -> + Some (ask_price, ask, Fee_schedule.Taker) + | Quote { bid_price; _ }, Quote_liquidity { bid; _ }, Some Market, Sell -> + Some (bid_price, bid, Fee_schedule.Taker) + | Quote { ask_price; _ }, Quote_liquidity { ask; _ }, Some (Limit limit), Buy + when Scalar.Price.compare ask_price limit <= 0 -> + Some (ask_price, ask, Fee_schedule.Taker) + | Quote { bid_price; _ }, Quote_liquidity { bid; _ }, Some (Limit limit), Sell + when Scalar.Price.compare bid_price limit >= 0 -> + Some (bid_price, bid, Fee_schedule.Taker) + | ( Trade { price; aggressor_side = Market_event.Sell; _ }, + Trade_liquidity quantity, + Some (Limit limit), + Buy ) + when Scalar.Price.compare price limit <= 0 -> + Some (price, quantity, Fee_schedule.Maker) + | ( Trade { price; aggressor_side = Market_event.Buy; _ }, + Trade_liquidity quantity, + Some (Limit limit), + Sell ) + when Scalar.Price.compare price limit >= 0 -> + Some (price, quantity, Fee_schedule.Maker) + | _ -> None + +let consume_observable side liquidity quantity = + match liquidity with + | Quote_liquidity { bid; ask } -> + if side = Order.Buy then + Result.map + (fun ask -> Quote_liquidity { bid; ask }) + (Scalar.Quantity.subtract ask quantity) + else + Result.map + (fun bid -> Quote_liquidity { bid; ask }) + (Scalar.Quantity.subtract bid quantity) + | Trade_liquidity available -> + Result.map + (fun value -> Trade_liquidity value) + (Scalar.Quantity.subtract available quantity) + +let start_slice_quote_trade state ~instruments ~oms + (market_slice : Market_slice.t) = + let instrument_map = + List.fold_left + (fun map instrument -> + Id.Instrument.Map.add instrument.Instrument.id instrument map) + Id.Instrument.Map.empty instruments + in + let prepare_event event = + match + Id.Instrument.Map.find_opt event.Market_event.instrument_id instrument_map + with + | None -> Error "market event refers to an unknown instrument" + | Some instrument -> + let* () = validate_market_event instrument event in + if + Ptime.compare event.event_at market_slice.start_at < 0 + || Ptime.compare event.event_at market_slice.end_at > 0 + || Ptime.compare event.received_at market_slice.received_at > 0 + then Error "market event falls outside its observable slice boundary" + else + let* liquidity = + match event.kind with + | Market_event.Quote { bid_quantity; ask_quantity; _ } -> + let* bid = event_capacity state instrument bid_quantity in + let* ask = event_capacity state instrument ask_quantity in + Ok (Quote_liquidity { bid; ask }) + | Trade { quantity; _ } -> + Result.map + (fun value -> Trade_liquidity value) + (event_capacity state instrument quantity) + in + Ok (event, instrument, liquidity) + in + let* events = + List.fold_right + (fun event result -> + let* prepared = prepare_event event in + let* remaining = result in + Ok (prepared :: remaining)) + market_slice.market_events (Ok []) + in + let eligible = + Oms.active_orders oms + |> List.filter (fun order -> + Int64.compare order.Order.eligible_after_slice_sequence + market_slice.slice_sequence + < 0 + && Ptime.compare order.created_at market_slice.start_at <= 0 + && + match order.trigger_state with + | Some (Order.Triggered { triggered_slice_sequence; _ }) -> + Int64.compare triggered_slice_sequence market_slice.slice_sequence + < 0 + | Some Order.Dormant | None -> true) + |> List.sort compare_execution_order + in + let order_ids = List.map (fun order -> order.Order.id) eligible in + let market_ioc_orders = + eligible + |> List.filter_map (fun order -> + if Order.is_ioc order && not (Order.is_dormant_stop order) then + Some order.Order.id + else None) + in + let rec make_events = function + | [] -> cursor (fun ~oms:_ -> Ok (Finished market_ioc_orders)) + | (event, instrument, liquidity) :: remaining_events -> + make_orders event instrument liquidity order_ids remaining_events + and make_orders event instrument liquidity remaining remaining_events = + Cursor + (fun current_oms -> + match remaining with + | [] -> + let (Cursor next) = make_events remaining_events in + next current_oms + | order_id :: remaining_orders -> ( + match Oms.find current_oms order_id with + | None -> Error "eligible order disappeared during market replay" + | Some order when not (Order.is_active order) -> + let (Cursor next) = + make_orders event instrument liquidity remaining_orders + remaining_events + in + next current_oms + | Some order + when not + (Id.Instrument.equal order.request.instrument_id + event.Market_event.instrument_id) -> + let (Cursor next) = + make_orders event instrument liquidity remaining_orders + remaining_events + in + next current_oms + | Some order when Order.is_dormant_stop order -> + let continuation = + make_orders event instrument liquidity remaining_orders + remaining_events + in + if event_trigger order event then + Ok + (Triggered + ( order.id, + event.event_at, + market_slice.slice_sequence, + continuation )) + else + let (Cursor next) = continuation in + next current_oms + | Some order -> ( + match event_opportunity order event liquidity with + | None -> + let (Cursor next) = + make_orders event instrument liquidity remaining_orders + remaining_events + in + next current_oms + | Some (price, available, fee_liquidity) -> + let quantity = + Scalar.Quantity.minimum available + (Order.remaining_quantity order) + in + if + Scalar.Quantity.is_zero quantity + || Order.is_fok order + && Scalar.Quantity.compare quantity + (Order.remaining_quantity order) + < 0 + then + let (Cursor next) = + make_orders event instrument liquidity remaining_orders + remaining_events + in + next current_oms + else + let* notional = Scalar.Money.notional price quantity in + let* fee_components, fee = + calculate_fee state ~instrument ~notional ~quantity + ~liquidity:fee_liquidity + ~fx_rates: + (List.map + (fun mark -> + (mark.Market_slice.currency, mark.rate)) + market_slice.fx_rates) + in + let proposed = + { + order_id = order.id; + quantity; + price; + fee; + fee_components; + liquidity = fee_liquidity; + executed_at = event.event_at; + price_attribution = None; + } + in + let continue applied_quantity = + if Scalar.Quantity.compare applied_quantity quantity > 0 + then + Error + "applied fill quantity exceeds observable liquidity" + else if + Scalar.Quantity.compare applied_quantity + Scalar.Quantity.zero + < 0 + then Error "applied fill quantity must be nonnegative" + else if + not + (Scalar.Quantity.is_multiple applied_quantity + ~lot:instrument.Instrument.lot_size) + then + Error + "applied fill quantity is not aligned to the \ + instrument lot size" + else + let* liquidity = + consume_observable order.request.side liquidity + applied_quantity + in + Ok + (make_orders event instrument liquidity + remaining_orders remaining_events) + in + Ok (Proposed (proposed, continue))))) + in + Ok (make_events events) + +type book_state = { + book_sequence : int64; + bids : Order_book_event.level list; + asks : Order_book_event.level list; +} + +type book_view = + | Book_snapshot of book_state + | Book_added of Order_book_event.side * Scalar.Price.t * Scalar.Quantity.t + | Book_reduced of Order_book_event.side * Scalar.Price.t * Scalar.Quantity.t + | Book_trade of + Scalar.Price.t * Scalar.Quantity.t * Market_event.aggressor_side + +let book_level_quantity price levels = + List.find_opt + (fun (level : Order_book_event.level) -> + Scalar.Price.compare level.price price = 0) + levels + |> Option.map (fun level -> level.Order_book_event.quantity) + |> Option.value ~default:Scalar.Quantity.zero + +let sort_book_levels side levels = + List.sort + (fun (left : Order_book_event.level) right -> + let comparison = Scalar.Price.compare left.price right.price in + match side with + | Order_book_event.Bid -> -comparison + | Order_book_event.Ask -> comparison) + levels + +let replace_book_level side price quantity levels = + let level = Order_book_event.level ~price ~quantity |> Result.get_ok in + level + :: List.filter + (fun (existing : Order_book_event.level) -> + Scalar.Price.compare existing.price price <> 0) + levels + |> sort_book_levels side + +let remove_book_level price levels = + List.filter + (fun (level : Order_book_event.level) -> + Scalar.Price.compare level.price price <> 0) + levels + +let consume_book_levels price quantity levels = + let rec consume reversed = function + | [] -> Error "order-book execution level disappeared" + | (level : Order_book_event.level) :: remaining -> + if Scalar.Price.compare level.price price <> 0 then + consume (level :: reversed) remaining + else if Scalar.Quantity.compare quantity level.quantity > 0 then + Error "applied fill quantity exceeds order-book liquidity" + else if Scalar.Quantity.compare quantity level.quantity = 0 then + Ok (List.rev_append reversed remaining) + else + let* remaining_quantity = + Scalar.Quantity.subtract level.quantity quantity + in + let* level = + Order_book_event.level ~price:level.price + ~quantity:remaining_quantity + in + Ok (List.rev_append reversed (level :: remaining)) + in + consume [] levels + +let consume_book_view order price quantity = function + | Book_snapshot book -> ( + match order.Order.request.side with + | Buy -> + Result.map + (fun asks -> Book_snapshot { book with asks }) + (consume_book_levels price quantity book.asks) + | Sell -> + Result.map + (fun bids -> Book_snapshot { book with bids }) + (consume_book_levels price quantity book.bids)) + | Book_added (side, added_price, available) + when Scalar.Price.compare price added_price = 0 -> + if Scalar.Quantity.compare quantity available > 0 then + Error "applied fill quantity exceeds order-book liquidity" + else if Scalar.Quantity.compare quantity available = 0 then + Ok (Book_added (side, added_price, Scalar.Quantity.zero)) + else + let* remaining = Scalar.Quantity.subtract available quantity in + Ok (Book_added (side, added_price, remaining)) + | view -> Ok view + +let valid_book depth_limit book = + List.length book.bids <= depth_limit + && List.length book.asks <= depth_limit + && + match (book.bids, book.asks) with + | bid :: _, ask :: _ -> Scalar.Price.compare bid.price ask.price <= 0 + | _ -> true + +let consume_feed_trade aggressor price quantity book = + let eligible level = + match aggressor with + | Market_event.Buy -> + Scalar.Price.compare level.Order_book_event.price price <= 0 + | Sell -> Scalar.Price.compare level.price price >= 0 + | Unknown -> false + in + let rec consume remaining consumed = function + | levels when Scalar.Quantity.is_zero remaining -> + Ok (List.rev_append consumed levels) + | level :: levels when eligible level -> + if Scalar.Quantity.compare level.quantity remaining <= 0 then + let* remaining = Scalar.Quantity.subtract remaining level.quantity in + consume remaining consumed levels + else + let* quantity = Scalar.Quantity.subtract level.quantity remaining in + let* level = Order_book_event.level ~price:level.price ~quantity in + Ok (List.rev_append consumed (level :: levels)) + | _ -> Error "order-book trade exceeds observable depth" + in + match aggressor with + | Market_event.Buy -> + Result.map + (fun asks -> { book with asks }) + (consume quantity [] book.asks) + | Sell -> + Result.map + (fun bids -> { book with bids }) + (consume quantity [] book.bids) + | Unknown -> Ok book + +let start_slice_order_book state ~instruments ~oms + (market_slice : Market_slice.t) = + let depth_limit = Option.value state.book_depth_limit ~default:0 in + if depth_limit = 0 then Error "order-book execution configuration is required" + else + let instrument_map = + List.fold_left + (fun map instrument -> + Id.Instrument.Map.add instrument.Instrument.id instrument map) + Id.Instrument.Map.empty instruments + in + let validate_event instrument (event : Order_book_event.t) = + let prices, quantities = + match event.kind with + | Snapshot { bids; asks } -> + ( List.map (fun level -> level.Order_book_event.price) (bids @ asks), + List.map + (fun level -> level.Order_book_event.quantity) + (bids @ asks) ) + | Set { price; quantity; _ } -> ([ price ], [ quantity ]) + | Delete { price; _ } -> ([ price ], []) + | Trade { price; quantity; _ } -> ([ price ], [ quantity ]) + in + if + not + (List.for_all + (fun price -> + Scalar.Price.is_multiple price + ~tick:instrument.Instrument.tick_size) + prices) + then Error "order-book price is not aligned to the instrument tick size" + else if + not + (List.for_all + (fun quantity -> + Scalar.Quantity.is_multiple quantity ~lot:instrument.lot_size) + quantities) + then Error "order-book quantity is not aligned to the instrument lot size" + else if + Ptime.compare event.event_at market_slice.start_at < 0 + || Ptime.compare event.event_at market_slice.end_at > 0 + || Ptime.compare event.available_at market_slice.available_at > 0 + || Ptime.compare event.received_at market_slice.received_at > 0 + then Error "order-book event falls outside its observable slice boundary" + else Ok () + in + let prepare (books, prepared) (event : Order_book_event.t) = + let* instrument = + match Id.Instrument.Map.find_opt event.instrument_id instrument_map with + | Some instrument -> Ok instrument + | None -> Error "order-book event refers to an unknown instrument" + in + let* () = validate_event instrument event in + let prior = Id.Instrument.Map.find_opt event.instrument_id books in + let* book, view = + match (prior, event.kind) with + | None, Snapshot { bids; asks } -> + let book = { book_sequence = event.book_sequence; bids; asks } in + if valid_book depth_limit book then Ok (book, Book_snapshot book) + else Error "order-book snapshot exceeds depth or crosses" + | Some _, Snapshot _ -> + Error "order-book bundle contains more than one snapshot" + | None, _ -> Error "order-book bundle must begin with a snapshot" + | Some prior, kind -> ( + if Int64.succ prior.book_sequence <> event.book_sequence then + Error "order-book sequences must be contiguous" + else + let next_sequence book = + { book with book_sequence = event.book_sequence } + in + match kind with + | Set { side; price; quantity } -> + let levels = + match side with + | Bid -> prior.bids + | Order_book_event.Ask -> prior.asks + in + let old_quantity = book_level_quantity price levels in + let levels = replace_book_level side price quantity levels in + let book = + match side with + | Order_book_event.Bid -> + next_sequence { prior with bids = levels } + | Order_book_event.Ask -> + next_sequence { prior with asks = levels } + in + if not (valid_book depth_limit book) then + Error "order-book update exceeds depth or crosses" + else if Scalar.Quantity.compare quantity old_quantity > 0 then + let* added = + Scalar.Quantity.subtract quantity old_quantity + in + Ok (book, Book_added (side, price, added)) + else + let* removed = + Scalar.Quantity.subtract old_quantity quantity + in + Ok (book, Book_reduced (side, price, removed)) + | Delete { side; price } -> + let levels = + match side with + | Bid -> prior.bids + | Order_book_event.Ask -> prior.asks + in + let old_quantity = book_level_quantity price levels in + if Scalar.Quantity.is_zero old_quantity then + Error "order-book delete refers to a missing level" + else + let levels = remove_book_level price levels in + let book = + match side with + | Order_book_event.Bid -> + next_sequence { prior with bids = levels } + | Order_book_event.Ask -> + next_sequence { prior with asks = levels } + in + Ok (book, Book_reduced (side, price, old_quantity)) + | Trade { price; quantity; aggressor_side } -> + let* book = + consume_feed_trade aggressor_side price quantity prior + in + Ok + ( next_sequence book, + Book_trade (price, quantity, aggressor_side) ) + | Snapshot _ -> assert false) + in + Ok + ( Id.Instrument.Map.add event.instrument_id book books, + (event, instrument, view) :: prepared ) + in + let* books, reversed = + List.fold_left + (fun result event -> + Result.bind result (fun state -> prepare state event)) + (Ok (Id.Instrument.Map.empty, [])) + market_slice.order_book_events + in + let expected = + List.map (fun instrument -> instrument.Instrument.id) instruments + |> Id.Instrument.Set.of_list + in + let observed = + Id.Instrument.Map.fold + (fun instrument_id _ ids -> Id.Instrument.Set.add instrument_id ids) + books Id.Instrument.Set.empty + in + if not (Id.Instrument.Set.equal expected observed) then + Error "order-book snapshots must cover every configured instrument" + else + let events = List.rev reversed in + let eligible = + Oms.active_orders oms + |> List.filter (fun order -> + Int64.compare order.Order.eligible_after_slice_sequence + market_slice.slice_sequence + < 0 + && Ptime.compare order.created_at market_slice.start_at <= 0) + |> List.sort compare_execution_order + in + let order_ids = List.map (fun order -> order.Order.id) eligible in + let market_ioc_orders = + List.filter_map + (fun order -> + if Order.is_ioc order && not (Order.is_dormant_stop order) then + Some order.Order.id + else None) + eligible + in + let queue_for_snapshot queues instrument book = + let rec build prior queues = function + | [] -> Ok queues + | order :: remaining -> ( + match Order.effective_kind order with + | Some (Order.Limit limit) + when Id.Instrument.equal order.request.instrument_id + instrument.Instrument.id -> + let opposite = + match order.request.side with + | Buy -> book.asks + | Sell -> book.bids + in + let marketable = + match opposite with + | [] -> false + | best :: _ -> ( + match order.request.side with + | Buy -> Scalar.Price.compare best.price limit <= 0 + | Sell -> Scalar.Price.compare best.price limit >= 0) + in + if marketable then build (order :: prior) queues remaining + else + let same_side = + match order.request.side with + | Buy -> book.bids + | Sell -> book.asks + in + let external_quantity = + book_level_quantity limit same_side + in + let* ahead = + List.fold_left + (fun result earlier -> + let* ahead = result in + match Order.effective_kind earlier with + | Some (Order.Limit earlier_limit) + when earlier.request.side = order.request.side + && Id.Instrument.equal + earlier.request.instrument_id + order.request.instrument_id + && Scalar.Price.compare earlier_limit limit = 0 + -> + Scalar.Quantity.add ahead + (Order.remaining_quantity earlier) + | _ -> Ok ahead) + (Ok external_quantity) prior + in + build (order :: prior) + (Id.Order.Map.add order.id ahead queues) + remaining + | _ -> build (order :: prior) queues remaining) + in + build [] queues eligible + in + let order_matches_level order side price = + match Order.effective_kind order with + | Some Order.Market -> + (order.request.side = Buy && side = Order_book_event.Ask) + || (order.request.side = Sell && side = Order_book_event.Bid) + | Some (Limit limit) -> + order.request.side = Buy + && side = Order_book_event.Ask + && Scalar.Price.compare price limit <= 0 + || order.request.side = Sell + && side = Order_book_event.Bid + && Scalar.Price.compare price limit >= 0 + | _ -> false + in + let levels_for_order order view = + match view with + | Book_snapshot book -> + let levels = + match order.Order.request.side with + | Buy -> book.asks + | Sell -> book.bids + in + List.filter + (fun level -> + order_matches_level order + (match order.request.side with + | Buy -> Order_book_event.Ask + | Sell -> Order_book_event.Bid) + level.Order_book_event.price) + levels + | Book_added (side, price, quantity) + when order_matches_level order side price + && not (Scalar.Quantity.is_zero quantity) -> + [ Order_book_event.level ~price ~quantity |> Result.get_ok ] + | _ -> [] + in + let reduce_queue queues instrument_id side price removed = + Id.Order.Map.mapi + (fun order_id ahead -> + match Oms.find oms order_id with + | Some order + when Id.Instrument.equal order.request.instrument_id instrument_id + && (match order.request.side with + | Buy -> side = Order_book_event.Bid + | Sell -> side = Order_book_event.Ask) + && + match Order.effective_kind order with + | Some (Limit limit) -> Scalar.Price.compare limit price = 0 + | _ -> false -> + if Scalar.Quantity.compare removed ahead >= 0 then + Scalar.Quantity.zero + else Scalar.Quantity.subtract ahead removed |> Result.get_ok + | _ -> ahead) + queues + in + let trade_allowances queues instrument_id price quantity aggressor = + Id.Order.Map.fold + (fun order_id ahead (queues, allowances) -> + match Oms.find oms order_id with + | Some order + when Id.Instrument.equal order.request.instrument_id instrument_id + && (match (order.request.side, aggressor) with + | Buy, Market_event.Sell | Sell, Buy -> true + | _ -> false) + && + match Order.effective_kind order with + | Some (Limit limit) -> ( + match order.request.side with + | Buy -> Scalar.Price.compare price limit <= 0 + | Sell -> Scalar.Price.compare price limit >= 0) + | _ -> false -> + let next_ahead = + if Scalar.Quantity.compare quantity ahead >= 0 then + Scalar.Quantity.zero + else Scalar.Quantity.subtract ahead quantity |> Result.get_ok + in + let through = + if Scalar.Quantity.compare quantity ahead <= 0 then + Scalar.Quantity.zero + else Scalar.Quantity.subtract quantity ahead |> Result.get_ok + in + ( Id.Order.Map.add order_id next_ahead queues, + Id.Order.Map.add order_id through allowances ) + | _ -> (queues, allowances)) + queues + (queues, Id.Order.Map.empty) + in + let rec make_events queues = function + | [] -> Ok (cursor (fun ~oms:_ -> Ok (Finished market_ioc_orders))) + | ((event : Order_book_event.t), instrument, view) :: remaining_events + -> + let* queues, allowances = + match view with + | Book_snapshot book -> + Result.map + (fun queues -> (queues, Id.Order.Map.empty)) + (queue_for_snapshot queues instrument book) + | Book_reduced (side, price, removed) -> + Ok + ( reduce_queue queues event.instrument_id side price removed, + Id.Order.Map.empty ) + | Book_trade (price, quantity, aggressor) -> + Ok + (trade_allowances queues event.instrument_id price quantity + aggressor) + | Book_added _ -> Ok (queues, Id.Order.Map.empty) + in + Ok + (make_orders queues allowances event instrument view order_ids + remaining_events) + and make_orders queues allowances event instrument view remaining + remaining_events = + Cursor + (fun current_oms -> + match remaining with + | [] -> + let* cursor = make_events queues remaining_events in + let (Cursor next) = cursor in + next current_oms + | order_id :: remaining_orders -> ( + match Oms.find current_oms order_id with + | None -> + Error "eligible order disappeared during order-book replay" + | Some order when not (Order.is_active order) -> + let (Cursor next) = + make_orders queues allowances event instrument view + remaining_orders remaining_events + in + next current_oms + | Some order + when not + (Id.Instrument.equal order.request.instrument_id + event.Order_book_event.instrument_id) -> + let (Cursor next) = + make_orders queues allowances event instrument view + remaining_orders remaining_events + in + next current_oms + | Some order when Order.is_dormant_stop order -> + let observed = + match view with + | Book_snapshot book -> ( + match order.request.side with + | Buy -> ( + match book.asks with + | level :: _ -> Some level.Order_book_event.price + | [] -> None) + | Sell -> ( + match book.bids with + | level :: _ -> Some level.Order_book_event.price + | [] -> None)) + | Book_added (_, price, _) + | Book_reduced (_, price, _) + | Book_trade (price, _, _) -> + Some price + in + let triggered = + match + (order.request.kind, order.request.side, observed) + with + | ( ( Stop trigger + | Stop_limit { trigger_price = trigger; _ } ), + Buy, + Some price ) -> + Scalar.Price.compare price trigger >= 0 + | ( ( Stop trigger + | Stop_limit { trigger_price = trigger; _ } ), + Sell, + Some price ) -> + Scalar.Price.compare price trigger <= 0 + | _ -> false + in + let continuation = + make_orders queues allowances event instrument view + remaining_orders remaining_events + in + if triggered then + Ok + (Triggered + ( order.id, + event.event_at, + market_slice.slice_sequence, + continuation )) + else + let (Cursor next) = continuation in + next current_oms + | Some order -> ( + let levels = levels_for_order order view in + let passive = Id.Order.Map.find_opt order.id allowances in + let* fok_capacity = + List.fold_left + (fun result (level : Order_book_event.level) -> + let* total = result in + let* capacity = + event_capacity state instrument level.quantity + in + Scalar.Quantity.add total capacity) + (Ok Scalar.Quantity.zero) levels + in + let opportunity = + match (levels, passive, view) with + | level :: _, _, _ -> + Some + ( level.price, + level.quantity, + Fee_schedule.Taker, + true ) + | [], Some quantity, Book_trade (price, _, _) -> + Some (price, quantity, Fee_schedule.Maker, false) + | _ -> None + in + match opportunity with + | None -> + let (Cursor next) = + make_orders queues allowances event instrument view + remaining_orders remaining_events + in + next current_oms + | Some (price, available, fee_liquidity, repeat_order) -> + let* capacity = + event_capacity state instrument available + in + let quantity = + Scalar.Quantity.minimum capacity + (Order.remaining_quantity order) + in + if + Scalar.Quantity.is_zero quantity + || Order.is_fok order + && Scalar.Quantity.compare + (if levels = [] then capacity + else fok_capacity) + (Order.remaining_quantity order) + < 0 + then + let (Cursor next) = + make_orders queues allowances event instrument view + remaining_orders remaining_events + in + next current_oms + else + let* notional = + Scalar.Money.notional price quantity + in + let* fee_components, fee = + calculate_fee state ~instrument ~notional ~quantity + ~liquidity:fee_liquidity + ~fx_rates: + (List.map + (fun mark -> + (mark.Market_slice.currency, mark.rate)) + market_slice.fx_rates) + in + let proposed = + { + order_id = order.id; + quantity; + price; + fee; + fee_components; + liquidity = fee_liquidity; + executed_at = event.event_at; + price_attribution = None; + } + in + let continue applied_quantity = + if + Scalar.Quantity.compare applied_quantity quantity + > 0 + then + Error + "applied fill quantity exceeds order-book \ + liquidity" + else if + Scalar.Quantity.compare applied_quantity + Scalar.Quantity.zero + < 0 + then + Error "applied fill quantity must be nonnegative" + else if + not + (Scalar.Quantity.is_multiple applied_quantity + ~lot:instrument.Instrument.lot_size) + then + Error + "applied fill quantity is not aligned to the \ + instrument lot size" + else + let* next_view = + if repeat_order then + consume_book_view order price applied_quantity + view + else Ok view + in + let next_orders = + if + repeat_order + && not + (Scalar.Quantity.is_zero applied_quantity) + then order_id :: remaining_orders + else remaining_orders + in + Ok + (make_orders queues allowances event instrument + next_view next_orders remaining_events) + in + Ok (Proposed (proposed, continue))))) + in + make_events Id.Order.Map.empty events + let finished market_ioc_orders = cursor (fun ~oms:_ -> Ok (Finished market_ioc_orders)) @@ -230,6 +1457,8 @@ let fold_slice state ~instruments ~oms market_slice ~init ~apply = match next cursor ~oms with | Error _ as error -> error | Ok (Finished market_ioc_orders) -> Ok (accumulator, market_ioc_orders) + | Ok (Triggered _) -> + Error "fold_slice cannot persist a triggered conditional order" | Ok (Proposed (proposed, continue)) -> let* accumulator, applied_quantity = apply accumulator proposed in let* cursor = continue applied_quantity in @@ -238,8 +1467,26 @@ let fold_slice state ~instruments ~oms market_slice ~init ~apply = fold init cursor let match_slice state ~instruments ~oms market_slice = - let apply fills proposed = Ok (proposed :: fills, proposed.quantity) in - match fold_slice state ~instruments ~oms market_slice ~init:[] ~apply with - | Error _ as error -> error - | Ok (fills, market_ioc_orders) -> - Ok { fills = List.rev fills; market_ioc_orders } + let ( let* ) result function_ = + match result with Ok value -> function_ value | Error _ as error -> error + in + let* cursor = start_slice state ~instruments ~oms market_slice in + let rec collect fills triggers cursor = + match next cursor ~oms with + | Error _ as error -> error + | Ok (Finished market_ioc_orders) -> + Ok + { + fills = List.rev fills; + triggers = List.rev triggers; + market_ioc_orders; + } + | Ok (Triggered (order_id, triggered_at, slice_sequence, cursor)) -> + collect fills + ((order_id, triggered_at, slice_sequence) :: triggers) + cursor + | Ok (Proposed (proposed, continue)) -> + let* cursor = continue proposed.quantity in + collect (proposed :: fills) triggers cursor + in + collect [] [] cursor diff --git a/lib/execution.mli b/lib/execution.mli index f44b169..70aeed3 100644 --- a/lib/execution.mli +++ b/lib/execution.mli @@ -1,17 +1,35 @@ (** Deterministic synchronized-slice execution simulation. *) type t +type missing_volume_policy = Reject_missing_volume | Zero_impact + +type cost_model = private { + half_spread_bps : int; + impact_coefficient_bps : int; + missing_volume_policy : missing_volume_policy; +} + +type price_attribution = private { + reference_price : Scalar.Price.t; + spread_adjustment : Scalar.Money.t; + impact_adjustment : Scalar.Money.t; + final_price : Scalar.Price.t; +} type proposed_fill = private { order_id : Id.Order.t; quantity : Scalar.Quantity.t; price : Scalar.Price.t; fee : Scalar.Money.t; + fee_components : Fee_schedule.calculated_component list; + liquidity : Fee_schedule.liquidity; executed_at : Ptime.t; + price_attribution : price_attribution option; } type match_result = private { fills : proposed_fill list; + triggers : (Id.Order.t * Ptime.t * int64) list; market_ioc_orders : Id.Order.t list; } @@ -19,6 +37,7 @@ type cursor type step = | Finished of Id.Order.t list + | Triggered of Id.Order.t * Ptime.t * int64 * cursor | Proposed of proposed_fill * (Scalar.Quantity.t -> (cursor, string) result) val cursor : (oms:Oms.t -> (step, string) result) -> cursor @@ -26,13 +45,36 @@ val cursor : (oms:Oms.t -> (step, string) result) -> cursor val create : participation_bps:int -> - fixed_fee:Scalar.Money.t -> - fee_bps:int -> + fee_schedules:Fee_schedule.t list -> + (t, string) result + +val create_conservative : + participation_bps:int -> + fee_schedules:Fee_schedule.t list -> + half_spread_bps:int -> + impact_coefficient_bps:int -> + missing_volume_policy:missing_volume_policy -> + (t, string) result + +val create_order_book : + participation_bps:int -> + fee_schedules:Fee_schedule.t list -> + max_depth_levels:int -> (t, string) result val participation_bps : t -> int -val fixed_fee : t -> Scalar.Money.t -val fee_bps : t -> int +val book_depth_limit : t -> int option +val fee_schedules : t -> Fee_schedule.t list +val cost_model : t -> cost_model option + +val calculate_fee : + t -> + instrument:Instrument.t -> + notional:Scalar.Money.t -> + quantity:Scalar.Quantity.t -> + liquidity:Fee_schedule.liquidity -> + fx_rates:(string * Scalar.Price.t) list -> + (Fee_schedule.calculated_component list * Scalar.Money.t, string) result val start_slice : t -> @@ -43,6 +85,34 @@ val start_slice : (** Start an immutable matching cursor from the orders eligible at the slice boundary. *) +val start_slice_next_open : + t -> + instruments:Instrument.t list -> + oms:Oms.t -> + Market_slice.t -> + (cursor, string) result + +val start_slice_adverse_touch : + t -> + instruments:Instrument.t list -> + oms:Oms.t -> + Market_slice.t -> + (cursor, string) result + +val start_slice_quote_trade : + t -> + instruments:Instrument.t list -> + oms:Oms.t -> + Market_slice.t -> + (cursor, string) result + +val start_slice_order_book : + t -> + instruments:Instrument.t list -> + oms:Oms.t -> + Market_slice.t -> + (cursor, string) result + val finished : Id.Order.t list -> cursor (** Build a cursor that immediately finishes. This supports execution models that intentionally produce no proposals. *) @@ -62,7 +132,9 @@ val fold_slice : ('a * Id.Order.t list, string) result (** Fold executable orders in liquidation-first, then sell-before-buy/FIFO order. The callback returns the quantity it actually applied; only that - quantity consumes the shared per-instrument slice capacity. *) + quantity consumes the shared per-instrument slice capacity. Returns an error + when a dormant stop triggers because the callback cannot persist trigger + state. *) val match_slice : t -> @@ -70,3 +142,5 @@ val match_slice : oms:Oms.t -> Market_slice.t -> (match_result, string) result +(** Pure deterministic matching. Conditional activations are returned in + [triggers] and cannot fill until a later slice. *) diff --git a/lib/execution_model.ml b/lib/execution_model.ml index 1a1b148..c5ed8ba 100644 --- a/lib/execution_model.ml +++ b/lib/execution_model.ml @@ -11,16 +11,191 @@ end type t = (module S) +type configuration_contract = { + version : string; + scenario_contract_versions : string list; + required_fields : string list; + supported_order_types : string list; + data_requirements : string list; + limits : Yojson.Safe.t; +} + module Completed_bar_v1 = struct let name = "completed_bar_v1" let start_slice = Execution.start_slice end +module Completed_bar_next_open_v1 = struct + let name = "completed_bar_next_open_v1" + let start_slice = Execution.start_slice_next_open +end + +module Completed_bar_adverse_touch_v1 = struct + let name = "completed_bar_adverse_touch_v1" + let start_slice = Execution.start_slice_adverse_touch +end + +module Quote_trade_v1 = struct + let name = "quote_trade_v1" + let start_slice = Execution.start_slice_quote_trade +end + +module Order_book_v1 = struct + let name = "order_book_v1" + let start_slice = Execution.start_slice_order_book +end + let of_module model = model let name (module Model : S) = Model.name -let builtins : t list = [ (module Completed_bar_v1) ] + +let builtins : t list = + [ + (module Completed_bar_v1); + (module Completed_bar_next_open_v1); + (module Completed_bar_adverse_touch_v1); + (module Quote_trade_v1); + (module Order_book_v1); + ] + let supported = List.map name builtins +let completed_bar_v1_contract = + { + version = "1"; + scenario_contract_versions = [ "1" ]; + required_fields = [ "version"; "participation_bps"; "fee_schedules" ]; + supported_order_types = [ "market"; "limit"; "stop"; "stop_limit" ]; + data_requirements = [ "completed_ohlcv_bars" ]; + limits = + `Assoc + [ + ( "participation_bps", + `Assoc [ ("minimum", `Int 0); ("maximum", `Int 10_000) ] ); + ]; + } + +let conservative_contract = + { + version = "1"; + scenario_contract_versions = [ "1" ]; + required_fields = + [ + "version"; + "participation_bps"; + "fee_schedules"; + "spread_model"; + "impact_model"; + ]; + supported_order_types = [ "market"; "limit"; "stop"; "stop_limit" ]; + data_requirements = + [ "completed_ohlcv_bars"; "bar_volume_for_linear_impact" ]; + limits = + `Assoc + [ + ( "participation_bps", + `Assoc [ ("minimum", `Int 0); ("maximum", `Int 10_000) ] ); + ( "half_spread_bps", + `Assoc [ ("minimum", `Int 0); ("maximum", `Int 10_000) ] ); + ( "impact_coefficient_bps", + `Assoc [ ("minimum", `Int 0); ("maximum", `Int 10_000) ] ); + ]; + } + +let quote_trade_contract = + { + version = "1"; + scenario_contract_versions = [ "1" ]; + required_fields = [ "version"; "participation_bps"; "fee_schedules" ]; + supported_order_types = [ "market"; "limit"; "stop"; "stop_limit" ]; + data_requirements = + [ + "causally_ordered_bid_ask_quotes"; + "aggressor_classified_trades_for_passive_fills"; + "completed_bars_for_valuation"; + ]; + limits = + `Assoc + [ + ( "participation_bps", + `Assoc [ ("minimum", `Int 0); ("maximum", `Int 10_000) ] ); + ]; + } + +let order_book_contract = + { + version = "1"; + scenario_contract_versions = [ "1" ]; + required_fields = + [ "version"; "participation_bps"; "fee_schedules"; "max_depth_levels" ]; + supported_order_types = [ "market"; "limit"; "stop"; "stop_limit" ]; + data_requirements = + [ + "slice_open_level_two_snapshot"; + "contiguous_absolute_level_updates"; + "aggressor_classified_depth_consuming_trades"; + "completed_bars_for_valuation"; + ]; + limits = + `Assoc + [ + ( "participation_bps", + `Assoc [ ("minimum", `Int 0); ("maximum", `Int 10_000) ] ); + ( "max_depth_levels", + `Assoc [ ("minimum", `Int 1); ("maximum", `Int 1024) ] ); + ]; + } + +let configuration_contract model = + match name model with + | "completed_bar_v1" -> completed_bar_v1_contract + | "completed_bar_next_open_v1" | "completed_bar_adverse_touch_v1" -> + conservative_contract + | "quote_trade_v1" -> quote_trade_contract + | "order_book_v1" -> order_book_contract + | unsupported -> + invalid_arg + (Printf.sprintf "execution model %S has no configuration contract" + unsupported) + +let supports_configuration model version = + let contract = configuration_contract model in + String.equal contract.version version + +let required_fields model version = + let contract = configuration_contract model in + if String.equal version contract.version then Ok contract.required_fields + else + Error + (Printf.sprintf + "unsupported execution configuration version %S for model %S" version + (name model)) + +let supports_contract model version = + List.mem version (configuration_contract model).scenario_contract_versions + +let strings values = `List (List.map (fun value -> `String value) values) + +let capabilities_to_yojson () = + `List + (List.map + (fun model -> + let contract = configuration_contract model in + `Assoc + [ + ("name", `String (name model)); + ("configuration_versions", strings [ contract.version ]); + ( "scenario_contract_versions", + strings contract.scenario_contract_versions ); + ("required_fields", strings contract.required_fields); + ( "configuration_required_fields", + `Assoc [ (contract.version, strings contract.required_fields) ] + ); + ("supported_order_types", strings contract.supported_order_types); + ("data_requirements", strings contract.data_requirements); + ("limits", contract.limits); + ]) + builtins) + let find requested = match List.find_opt (fun model -> String.equal requested (name model)) builtins diff --git a/lib/execution_model.mli b/lib/execution_model.mli index 06336f8..f8641be 100644 --- a/lib/execution_model.mli +++ b/lib/execution_model.mli @@ -19,10 +19,24 @@ end type t +type configuration_contract = private { + version : string; + scenario_contract_versions : string list; + required_fields : string list; + supported_order_types : string list; + data_requirements : string list; + limits : Yojson.Safe.t; +} + val of_module : (module S) -> t val name : t -> string val find : string -> (t, string) result val supported : string list +val configuration_contract : t -> configuration_contract +val supports_configuration : t -> string -> bool +val required_fields : t -> string -> (string list, string) result +val supports_contract : t -> string -> bool +val capabilities_to_yojson : unit -> Yojson.Safe.t val start_slice : t -> diff --git a/lib/external_replay.ml b/lib/external_replay.ml index 86850d6..de770f5 100644 --- a/lib/external_replay.ml +++ b/lib/external_replay.ml @@ -31,6 +31,26 @@ type stream_state = { audit_count : int64; } +let reducer ?sequence message = + Diagnostic.make ?sequence ~code:Diagnostic.Reducer_failed + ~phase:Diagnostic.Reducer message + +let replay ?sequence message = + Diagnostic.make ?sequence ~code:Diagnostic.Replay_failed + ~phase:Diagnostic.Replay message + +let reducer_result ?sequence result = + Result.map_error + (fun message -> + if + String.starts_with + ~prefix:"internal event count exceeds configured limit" message + then + Diagnostic.make ?sequence ~code:Diagnostic.Resource_limit + ~phase:Diagnostic.Reducer message + else reducer ?sequence message) + result + let ( let* ) result function_ = match result with Ok value -> function_ value | Error _ as error -> error @@ -42,11 +62,14 @@ let initialization_of_scenario ~scenario_sha256 (scenario : Scenario.t) = metadata = scenario.metadata; run_id = scenario.run_id; base_currency = scenario.base_currency; - initial_cash = scenario.initial_cash; + initial_portfolio = scenario.initial_portfolio; instruments = scenario.instruments; + venue_calendars = scenario.venue_calendars; risk = scenario.risk; execution_model = scenario.execution_model; execution = scenario.execution; + financing = scenario.financing; + settlement = scenario.settlement; } let initialization_of_header ~scenario_sha256 (header : Scenario.stream_header) @@ -58,19 +81,26 @@ let initialization_of_header ~scenario_sha256 (header : Scenario.stream_header) metadata = header.metadata; run_id = header.run_id; base_currency = header.base_currency; - initial_cash = header.initial_cash; + initial_portfolio = header.initial_portfolio; instruments = header.instruments; + venue_calendars = header.venue_calendars; risk = header.risk; execution_model = header.execution_model; execution = header.execution; + financing = header.financing; + settlement = header.settlement; } -let create_runner ~run_id ~scenario_sha256 ~risk ~execution_model ~execution - ~max_internal_events ~initial_cash = +let create_runner ~contract_version ~run_id ~scenario_sha256 ~risk + ~venue_calendars ~execution_model ~execution ~financing ~settlement + ~max_internal_events ~initial_portfolio = let* config = - Engine.config ~risk ~execution_model ~execution ~max_internal_events + Engine.config ~contract_version ~risk ~venue_calendars ~execution_model + ~execution ~financing ~settlement ~max_internal_events + |> reducer_result in - Runner.create ~run_id ~scenario_sha256 ~config ~initial_cash + Runner.create ~run_id ~scenario_sha256 ~config ~initial_portfolio + |> reducer_result let append_events journal events = match journal with @@ -85,46 +115,80 @@ let append_events journal events = let add_audit_count count events = let added = Int64.of_int (List.length events) in if Int64.compare count (Int64.sub Int64.max_int added) > 0 then - Error "audit event count is exhausted" + Error (replay "audit event count is exhausted") else Ok (Int64.add count added) let rec drive respond progress = match Runner.strategy_request progress with | Some (context, event) -> let* intents = respond context event in - let* progress = Runner.resume progress intents in + let* progress = Runner.resume progress intents |> reducer_result in drive respond progress | None -> ( match Runner.slice_result progress with | Some result -> Ok result - | None -> Error "interactive engine reached an invalid progress state") + | None -> + Error (replay "interactive engine reached an invalid progress state")) let process_slice respond runner market_slice = - let* progress = Runner.process_slice runner market_slice in + let* progress = + Runner.process_slice runner market_slice + |> reducer_result ~sequence:market_slice.Market_slice.slice_sequence + in drive respond progress -let close_journal = function - | None -> () - | Some journal -> Journal.close_preserving_partial journal +let create_artifacts ~durability ~journal_path ~transcript_path = + let* journal = Journal.create ~durability journal_path in + match Strategy_transcript.create ~durability transcript_path with + | Ok transcript -> Ok (journal, transcript) + | Error _ as error -> + Journal.close_preserving_partial journal; + error + +let close_artifacts (journal, transcript) = + Journal.close_preserving_partial journal; + Strategy_transcript.close_preserving_partial transcript + +let protect_artifacts artifacts run = + try run () + with exception_ -> + let backtrace = Printexc.get_raw_backtrace () in + close_artifacts artifacts; + Printexc.raise_with_backtrace exception_ backtrace -let run ~env ~scenario_sha256 ~journal_path ~transcript_path ~strategy_command - ~strategy_timeout (scenario : Scenario.t) = +let commit_artifacts (journal, transcript) = + Artifact_writer.commit + [ Journal.artifact journal; Strategy_transcript.artifact transcript ] + +let run ?(durability = Artifact_writer.Buffered) ~env ~scenario_sha256 + ~journal_path ~transcript_path ~strategy_command ~strategy_timeout + (scenario : Scenario.t) = if scenario.schedule <> [] then - Error "external strategy replay requires an empty scenario schedule" + Error + (replay "external strategy replay requires an empty scenario schedule") else + let initialization = initialization_of_scenario ~scenario_sha256 scenario in + let* () = + Strategy_process.validate_configuration ~command:strategy_command + ~timeout:strategy_timeout ~initialization + in let* initial = - create_runner ~run_id:scenario.run_id ~scenario_sha256 ~risk:scenario.risk + create_runner ~contract_version:scenario.contract_version + ~run_id:scenario.run_id ~scenario_sha256 ~risk:scenario.risk + ~venue_calendars:scenario.venue_calendars ~execution_model:scenario.execution_model ~execution:scenario.execution + ~financing:scenario.financing ~settlement:scenario.settlement ~max_internal_events:scenario.max_internal_events - ~initial_cash:scenario.initial_cash + ~initial_portfolio:scenario.initial_portfolio + in + let* journal, transcript = + create_artifacts ~durability ~journal_path ~transcript_path in - let* journal = Journal.create journal_path in let journal_ref = Some journal in let session_result = - Strategy_process.with_session ~env ~command:strategy_command - ~timeout:strategy_timeout ~transcript_path - ~initialization:(initialization_of_scenario ~scenario_sha256 scenario) - (fun session -> + protect_artifacts (journal, transcript) @@ fun () -> + Strategy_process.with_staged_session ~env ~command:strategy_command + ~timeout:strategy_timeout ~transcript ~initialization (fun session -> let respond = Strategy_process.on_event session in let step result market_slice = let* state, audits_rev = result in @@ -135,7 +199,9 @@ let run ~env ~scenario_sha256 ~journal_path ~transcript_path ~strategy_command let* state, audits_rev = List.fold_left step (Ok (initial, [])) scenario.slices in - let* state, valuation, completion_events = Runner.complete state in + let* state, valuation, completion_events = + Runner.complete state |> reducer_result + in let* () = append_events journal_ref completion_events in Ok ( state, @@ -144,10 +210,10 @@ let run ~env ~scenario_sha256 ~journal_path ~transcript_path ~strategy_command in match session_result with | Error _ as error -> - close_journal journal_ref; + close_artifacts (journal, transcript); error | Ok ((state, valuation, audits), strategy) -> ( - match Journal.commit journal with + match commit_artifacts (journal, transcript) with | Error _ as error -> error | Ok () -> Ok @@ -163,16 +229,20 @@ let validate_stream_pass ~scenario_sha256 channel = Scenario_stream.fold_channel channel ~init:(fun header -> let* runner = - create_runner ~run_id:header.Scenario.run_id ~scenario_sha256 - ~risk:header.risk ~execution_model:header.execution_model - ~execution:header.execution + create_runner ~contract_version:header.contract_version + ~run_id:header.Scenario.run_id ~scenario_sha256 ~risk:header.risk + ~venue_calendars:header.venue_calendars + ~execution_model:header.execution_model ~execution:header.execution + ~financing:header.financing ~settlement:header.settlement ~max_internal_events:header.max_internal_events - ~initial_cash:header.initial_cash + ~initial_portfolio:header.initial_portfolio in Ok (runner, initialization_of_header ~scenario_sha256 header, 0L)) ~step:(fun (runner, initialization, slice_count) item -> if item.Scenario.intents <> [] then - Error "external strategy replay requires empty streamed intents" + Error + (replay ~sequence:item.market_slice.slice_sequence + "external strategy replay requires empty streamed intents") else let* runner, _ = process_slice (fun _ _ -> Ok []) runner item.market_slice @@ -180,25 +250,29 @@ let validate_stream_pass ~scenario_sha256 channel = Ok (runner, initialization, Int64.succ slice_count)) ~finish:(fun (runner, initialization, counted_slices) ~slice_count -> if not (Int64.equal counted_slices slice_count) then - Error "scenario stream slice count changed during validation" + Error (replay "scenario stream slice count changed during validation") else - let* _, _, _ = Runner.complete runner in + let* _, _, _ = Runner.complete runner |> reducer_result in Ok { initialization; slice_count }) let replay_stream_pass ~scenario_sha256 ~journal ~session channel = Scenario_stream.fold_channel channel ~init:(fun header -> let* runner = - create_runner ~run_id:header.Scenario.run_id ~scenario_sha256 - ~risk:header.risk ~execution_model:header.execution_model - ~execution:header.execution + create_runner ~contract_version:header.contract_version + ~run_id:header.Scenario.run_id ~scenario_sha256 ~risk:header.risk + ~venue_calendars:header.venue_calendars + ~execution_model:header.execution_model ~execution:header.execution + ~financing:header.financing ~settlement:header.settlement ~max_internal_events:header.max_internal_events - ~initial_cash:header.initial_cash + ~initial_portfolio:header.initial_portfolio in Ok { runner; journal = Some journal; audit_count = 0L }) ~step:(fun state item -> if item.Scenario.intents <> [] then - Error "external strategy replay requires empty streamed intents" + Error + (replay ~sequence:item.market_slice.slice_sequence + "external strategy replay requires empty streamed intents") else let* runner, events = process_slice @@ -209,16 +283,18 @@ let replay_stream_pass ~scenario_sha256 ~journal ~session channel = let* audit_count = add_audit_count state.audit_count events in Ok { state with runner; audit_count }) ~finish:(fun state ~slice_count -> - let* runner, valuation, events = Runner.complete state.runner in + let* runner, valuation, events = + Runner.complete state.runner |> reducer_result + in let* () = append_events state.journal events in let* audit_count = add_audit_count state.audit_count events in Ok (runner, valuation, audit_count, slice_count)) -let run_stream ~env ~journal_path ~transcript_path ~strategy_command - ~strategy_timeout path = - let journal_ref = ref None in +let run_stream ?(durability = Artifact_writer.Buffered) ~env ~journal_path + ~transcript_path ~strategy_command ~strategy_timeout path = + let artifacts_ref = ref None in let fail result = - close_journal !journal_ref; + Option.iter close_artifacts !artifacts_ref; result in try @@ -229,13 +305,23 @@ let run_stream ~env ~journal_path ~transcript_path ~strategy_command seek_in channel 0; let validated_sha256 = Sha256.digest_channel channel in if not (String.equal scenario_sha256 validated_sha256) then - Error "scenario stream changed during validation" + Error + (Diagnostic.make ~code:Diagnostic.Scenario_stream_changed + ~phase:Diagnostic.Input + "scenario stream changed during validation") else - let* journal = Journal.create journal_path in - journal_ref := Some journal; + let* () = + Strategy_process.validate_configuration ~command:strategy_command + ~timeout:strategy_timeout ~initialization:validated.initialization + in + let* journal, transcript = + create_artifacts ~durability ~journal_path ~transcript_path + in + artifacts_ref := Some (journal, transcript); let session_result = - Strategy_process.with_session ~env ~command:strategy_command - ~timeout:strategy_timeout ~transcript_path + protect_artifacts (journal, transcript) @@ fun () -> + Strategy_process.with_staged_session ~env ~command:strategy_command + ~timeout:strategy_timeout ~transcript ~initialization:validated.initialization (fun session -> seek_in channel 0; let* runner, valuation, audit_count, slice_count = @@ -244,13 +330,16 @@ let run_stream ~env ~journal_path ~transcript_path ~strategy_command seek_in channel 0; let replayed_sha256 = Sha256.digest_channel channel in if not (String.equal scenario_sha256 replayed_sha256) then - Error "scenario stream changed during replay" + Error + (Diagnostic.make ~code:Diagnostic.Scenario_stream_changed + ~phase:Diagnostic.Input + "scenario stream changed during replay") else Ok (runner, valuation, audit_count, slice_count)) in match session_result with | Error _ as error -> fail error | Ok ((runner, valuation, audit_count, slice_count), strategy) -> ( - match Journal.commit journal with + match commit_artifacts (journal, transcript) with | Error _ as error -> error | Ok () -> Ok @@ -266,5 +355,10 @@ let run_stream ~env ~journal_path ~transcript_path ~strategy_command slice_count; strategy; })) - with Sys_error message -> - fail (Error ("could not read scenario stream: " ^ message)) + with Sys_error message as exception_ -> + fail + (Error + (Diagnostic.of_exception ~code:Diagnostic.Input_io + ~phase:Diagnostic.Input + ~message:("could not read scenario stream: " ^ message) + exception_)) diff --git a/lib/external_replay.mli b/lib/external_replay.mli index 13a96e3..90a71a5 100644 --- a/lib/external_replay.mli +++ b/lib/external_replay.mli @@ -21,6 +21,7 @@ type streamed_result = private { } val run : + ?durability:Artifact_writer.durability -> env:Eio_unix.Stdenv.base -> scenario_sha256:string -> journal_path:string -> @@ -28,13 +29,14 @@ val run : strategy_command:string list -> strategy_timeout:float -> Scenario.t -> - (result, string) Stdlib.result + (result, Diagnostic.t) Stdlib.result val run_stream : + ?durability:Artifact_writer.durability -> env:Eio_unix.Stdenv.base -> journal_path:string -> transcript_path:string -> strategy_command:string list -> strategy_timeout:float -> string -> - (streamed_result, string) Stdlib.result + (streamed_result, Diagnostic.t) Stdlib.result diff --git a/lib/fee_schedule.ml b/lib/fee_schedule.ml new file mode 100644 index 0000000..3840253 --- /dev/null +++ b/lib/fee_schedule.ml @@ -0,0 +1,277 @@ +type rounding = Up | Down | Nearest +type liquidity = Maker | Taker +type applicability = Any | Maker_only | Taker_only + +type basis = + | Fixed of Scalar.Money.t + | Notional_bps of int + | Per_unit of Scalar.Money.t + +type component = { + name : string; + currency : string; + basis : basis; + rounding : rounding; + applicability : applicability; +} + +type t = { + schedule_id : string; + instrument_id : Id.Instrument.t; + settlement_currency : string; + minimum : Scalar.Money.t option; + maximum : Scalar.Money.t option; + components : component list; +} + +type calculated_component = { + name : string; + kind : string; + currency : string; + amount : Scalar.Money.t; + quote_amount : Scalar.Money.t; +} + +let ( let* ) result function_ = + match result with Ok value -> function_ value | Error _ as error -> error + +let valid_token value = + String.length value > 0 + && String.for_all + (fun character -> + let code = Char.code character in + code >= 0x21 && code <> 0x7f) + value + +let create_component ~name ~currency ~basis ~rounding ~applicability = + if not (valid_token name) then + Error "fee component name must not be empty or contain whitespace" + else if not (valid_token currency) then + Error "fee component currency must not be empty or contain whitespace" + else + match basis with + | Notional_bps bps when bps < -10_000 || bps > 10_000 -> + Error "fee component basis points must be between -10000 and 10000" + | _ -> Ok { name; currency; basis; rounding; applicability } + +let create ~schedule_id ~instrument_id ~settlement_currency ~minimum ~maximum + ~(components : component list) = + let nonnegative = function + | None -> true + | Some value -> Scalar.Money.compare value Scalar.Money.zero >= 0 + in + let names = + List.map (fun (component : component) -> component.name) components + in + if not (valid_token schedule_id) then + Error "fee schedule ID must not be empty or contain whitespace" + else if not (valid_token settlement_currency) then + Error "fee settlement currency must not be empty or contain whitespace" + else if components = [] then Error "fee schedule must contain a component" + else if List.length names <> List.length (List.sort_uniq String.compare names) + then Error "fee component names must be unique within a schedule" + else if not (nonnegative minimum) then Error "fee minimum must be nonnegative" + else if not (nonnegative maximum) then Error "fee maximum must be nonnegative" + else + match (minimum, maximum) with + | Some lower, Some upper when Scalar.Money.compare lower upper > 0 -> + Error "fee minimum must not exceed fee maximum" + | _ -> + Ok + { + schedule_id; + instrument_id; + settlement_currency; + minimum; + maximum; + components; + } + +let schedule_id value = value.schedule_id +let instrument_id value = value.instrument_id +let settlement_currency value = value.settlement_currency +let minimum value = value.minimum +let maximum value = value.maximum +let components value = value.components +let component_name (value : component) = value.name +let component_currency (value : component) = value.currency +let component_basis (value : component) = value.basis +let component_rounding (value : component) = value.rounding +let component_applicability (value : component) = value.applicability + +let rounding_to_string = function + | Up -> "up" + | Down -> "down" + | Nearest -> "nearest" + +let rounding_of_string = function + | "up" -> Ok Up + | "down" -> Ok Down + | "nearest" -> Ok Nearest + | value -> Error (Printf.sprintf "unsupported fee rounding %S" value) + +let applicability_to_string = function + | Any -> "any" + | Maker_only -> "maker" + | Taker_only -> "taker" + +let applicability_of_string = function + | "any" -> Ok Any + | "maker" -> Ok Maker_only + | "taker" -> Ok Taker_only + | value -> Error (Printf.sprintf "unsupported fee applicability %S" value) + +let basis_kind = function + | Fixed _ -> "fixed" + | Notional_bps _ -> "notional_bps" + | Per_unit _ -> "per_unit" + +let divide ~rounding numerator denominator = + if Z.equal denominator Z.zero then + Error "fee conversion rate must be positive" + else + let sign = Z.sign numerator in + let absolute = Z.abs numerator in + let quotient, remainder = Z.ediv_rem absolute denominator in + let rounded = + match rounding with + | Down -> quotient + | Up -> if Z.equal remainder Z.zero then quotient else Z.succ quotient + | Nearest -> + if Z.compare (Z.mul remainder (Z.of_int 2)) denominator >= 0 then + Z.succ quotient + else quotient + in + let signed = if sign < 0 then Z.neg rounded else rounded in + if Z.fits_int64 signed then Ok (Scalar.Money.of_micros (Z.to_int64 signed)) + else Error "fee calculation overflow" + +let rate currency fx_rates = + match List.assoc_opt currency fx_rates with + | Some value -> Ok value + | None -> Error ("missing fee FX rate for currency " ^ currency) + +let convert ~rounding ~fx_rates ~source_currency ~target_currency amount = + if String.equal source_currency target_currency then Ok amount + else + let* source_rate = rate source_currency fx_rates in + let* target_rate = rate target_currency fx_rates in + divide ~rounding + Z.( + mul + (of_int64 (Scalar.Money.to_micros amount)) + (of_int64 (Scalar.Price.to_micros source_rate))) + (Z.of_int64 (Scalar.Price.to_micros target_rate)) + +let applies component liquidity = + match (component.applicability, liquidity) with + | Any, _ | Maker_only, Maker | Taker_only, Taker -> true + | Maker_only, Taker | Taker_only, Maker -> false + +let raw_amount component ~notional ~quantity ~quote_currency ~fx_rates = + match component.basis with + | Fixed value -> Ok value + | Notional_bps bps -> + let* native_notional = + convert ~rounding:component.rounding ~fx_rates + ~source_currency:quote_currency ~target_currency:component.currency + notional + in + divide ~rounding:component.rounding + Z.(mul (of_int64 (Scalar.Money.to_micros native_notional)) (of_int bps)) + (Z.of_int 10_000) + | Per_unit value -> + divide ~rounding:component.rounding + Z.( + mul + (of_int64 (Scalar.Money.to_micros value)) + (of_int64 (Scalar.Quantity.to_micros quantity))) + (Z.of_int64 Scalar.Quantity.scale) + +let calculate schedule ~quote_currency ~notional ~quantity ~liquidity ~fx_rates + = + let calculate_component component = + let* amount = + raw_amount component ~notional ~quantity ~quote_currency ~fx_rates + in + let* quote_amount = + convert ~rounding:component.rounding ~fx_rates + ~source_currency:component.currency ~target_currency:quote_currency + amount + in + Ok + { + name = component.name; + kind = basis_kind component.basis; + currency = component.currency; + amount; + quote_amount; + } + in + let rec collect result = function + | [] -> Ok (List.rev result) + | component :: remaining when not (applies component liquidity) -> + collect result remaining + | component :: remaining -> + let* calculated = calculate_component component in + collect (calculated :: result) remaining + in + let* calculated = collect [] schedule.components in + let* settlement_total = + List.fold_left + (fun result component -> + let* total = result in + let* amount = + convert ~rounding:Nearest ~fx_rates + ~source_currency:component.currency + ~target_currency:schedule.settlement_currency component.amount + in + Scalar.Money.add total amount) + (Ok Scalar.Money.zero) calculated + in + let bounded = + let after_minimum = + match schedule.minimum with + | Some minimum when Scalar.Money.compare settlement_total minimum < 0 -> + minimum + | _ -> settlement_total + in + match schedule.maximum with + | Some maximum when Scalar.Money.compare after_minimum maximum > 0 -> + maximum + | _ -> after_minimum + in + let* adjustment = Scalar.Money.subtract bounded settlement_total in + let* calculated = + if Scalar.Money.equal adjustment Scalar.Money.zero then Ok calculated + else + let* quote_amount = + convert ~rounding:Nearest ~fx_rates + ~source_currency:schedule.settlement_currency + ~target_currency:quote_currency adjustment + in + let name, kind = + if Scalar.Money.compare adjustment Scalar.Money.zero > 0 then + ("minimum_adjustment", "minimum_adjustment") + else ("maximum_adjustment", "maximum_adjustment") + in + Ok + (calculated + @ [ + { + name; + kind; + currency = schedule.settlement_currency; + amount = adjustment; + quote_amount; + }; + ]) + in + let* total = + List.fold_left + (fun result component -> + let* total = result in + Scalar.Money.add total component.quote_amount) + (Ok Scalar.Money.zero) calculated + in + Ok (calculated, total) diff --git a/lib/fee_schedule.mli b/lib/fee_schedule.mli new file mode 100644 index 0000000..636f832 --- /dev/null +++ b/lib/fee_schedule.mli @@ -0,0 +1,70 @@ +(** Deterministic, composable execution-fee schedules. *) + +type rounding = + | Up + | Down + | Nearest + (** Rounding is sign-symmetric: [Up] rounds away from zero, [Down] rounds + toward zero, and [Nearest] rounds half away from zero. *) + +type liquidity = Maker | Taker +type applicability = Any | Maker_only | Taker_only + +type basis = + | Fixed of Scalar.Money.t + | Notional_bps of int + | Per_unit of Scalar.Money.t + +type component +type t + +type calculated_component = private { + name : string; + kind : string; + currency : string; + amount : Scalar.Money.t; + quote_amount : Scalar.Money.t; +} + +val create_component : + name:string -> + currency:string -> + basis:basis -> + rounding:rounding -> + applicability:applicability -> + (component, string) result + +val create : + schedule_id:string -> + instrument_id:Id.Instrument.t -> + settlement_currency:string -> + minimum:Scalar.Money.t option -> + maximum:Scalar.Money.t option -> + components:component list -> + (t, string) result + +val schedule_id : t -> string +val instrument_id : t -> Id.Instrument.t +val settlement_currency : t -> string +val minimum : t -> Scalar.Money.t option +val maximum : t -> Scalar.Money.t option +val components : t -> component list +val component_name : component -> string +val component_currency : component -> string +val component_basis : component -> basis +val component_rounding : component -> rounding +val component_applicability : component -> applicability +val rounding_to_string : rounding -> string +val rounding_of_string : string -> (rounding, string) result +val applicability_to_string : applicability -> string +val applicability_of_string : string -> (applicability, string) result +val basis_kind : basis -> string + +val calculate : + t -> + quote_currency:string -> + notional:Scalar.Money.t -> + quantity:Scalar.Quantity.t -> + liquidity:liquidity -> + fx_rates:(string * Scalar.Price.t) list -> + (calculated_component list * Scalar.Money.t, string) result diff --git a/lib/fill.ml b/lib/fill.ml index b190905..d315356 100644 --- a/lib/fill.ml +++ b/lib/fill.ml @@ -8,12 +8,13 @@ type t = { price : Scalar.Price.t; notional : Scalar.Money.t; fee : Scalar.Money.t; + fee_components : Fee_schedule.calculated_component list; executed_at : Ptime.t; slice_sequence : int64; } let create ~id ~order_id ~instrument_id ~quote_currency ~side ~quantity ~price - ~fee ~executed_at ~slice_sequence = + ~fee ~fee_components ~executed_at ~slice_sequence = if not (Scalar.Quantity.is_positive quantity) then Error "fill quantity must be positive" else if String.length quote_currency = 0 then @@ -26,8 +27,6 @@ let create ~id ~order_id ~instrument_id ~quote_currency ~side ~quantity ~price code >= 0x21 && code <> 0x7f) quote_currency) then Error "fill quote currency must not contain whitespace" - else if Scalar.Money.compare fee Scalar.Money.zero < 0 then - Error "fill fee must be nonnegative" else if Int64.compare slice_sequence 0L <= 0 then Error "fill slice sequence must be positive" else @@ -36,20 +35,36 @@ let create ~id ~order_id ~instrument_id ~quote_currency ~side ~quantity ~price | Ok notional when Scalar.Money.equal notional Scalar.Money.zero -> Error "fill notional must be at least one money micro-unit" | Ok notional -> - Ok - { - id; - order_id; - instrument_id; - quote_currency; - side; - quantity; - price; - notional; - fee; - executed_at; - slice_sequence; - } + let component_total = + List.fold_left + (fun result component -> + Result.bind result (fun total -> + Scalar.Money.add total component.Fee_schedule.quote_amount)) + (Ok Scalar.Money.zero) fee_components + in + let component_total_valid = + match component_total with + | Ok total -> Scalar.Money.equal total fee + | Error _ -> false + in + if fee_components <> [] && not component_total_valid then + Error "fill fee components must sum to the fill fee" + else + Ok + { + id; + order_id; + instrument_id; + quote_currency; + side; + quantity; + price; + notional; + fee; + fee_components; + executed_at; + slice_sequence; + } let equal left right = Id.Fill.equal left.id right.id @@ -61,6 +76,7 @@ let equal left right = && Scalar.Price.equal left.price right.price && Scalar.Money.equal left.notional right.notional && Scalar.Money.equal left.fee right.fee + && left.fee_components = right.fee_components && Ptime.equal left.executed_at right.executed_at && Int64.equal left.slice_sequence right.slice_sequence diff --git a/lib/fill.mli b/lib/fill.mli index fabf5c6..67e6aee 100644 --- a/lib/fill.mli +++ b/lib/fill.mli @@ -10,6 +10,7 @@ type t = private { price : Scalar.Price.t; notional : Scalar.Money.t; fee : Scalar.Money.t; + fee_components : Fee_schedule.calculated_component list; executed_at : Ptime.t; slice_sequence : int64; } @@ -23,6 +24,7 @@ val create : quantity:Scalar.Quantity.t -> price:Scalar.Price.t -> fee:Scalar.Money.t -> + fee_components:Fee_schedule.calculated_component list -> executed_at:Ptime.t -> slice_sequence:int64 -> (t, string) result diff --git a/lib/financing.ml b/lib/financing.ml new file mode 100644 index 0000000..c86fba2 --- /dev/null +++ b/lib/financing.ml @@ -0,0 +1,159 @@ +type day_count = Actual_365 | Actual_360 +type compounding = Simple | Daily +type missing_data = Reject | Zero +type locate_policy = Reject_order | Clip_fill +type recall_policy = Reject_new_shorts | Close_out + +type policy = { + day_count : day_count; + compounding : compounding; + borrow_missing_data : missing_data; + cash_missing_data : missing_data; + locate_policy : locate_policy; + recall_policy : recall_policy; +} + +type borrow_observation = { + instrument_id : Id.Instrument.t; + effective_at : Ptime.t; + available_quantity : Scalar.Quantity.t; + annual_rate_bps : int; + recalled : bool; +} + +type cash_rate_observation = { + currency : string; + effective_at : Ptime.t; + credit_rate_bps : int; + debit_rate_bps : int; +} + +let policy ~day_count ~compounding ~borrow_missing_data ~cash_missing_data + ~locate_policy ~recall_policy = + { + day_count; + compounding; + borrow_missing_data; + cash_missing_data; + locate_policy; + recall_policy; + } + +let valid_currency value = + String.length value > 0 + && String.for_all + (fun character -> + let code = Char.code character in + code >= 0x21 && code <> 0x7f) + value + +let valid_rate value = value >= -1_000_000 && value <= 1_000_000 + +let borrow_observation ~instrument_id ~effective_at ~available_quantity + ~annual_rate_bps ~recalled = + if not (valid_rate annual_rate_bps) then + Error "borrow annual rate basis points must be between -1000000 and 1000000" + else if recalled && not (Scalar.Quantity.is_zero available_quantity) then + Error "recalled borrow availability must be zero" + else + Ok + { + instrument_id; + effective_at; + available_quantity; + annual_rate_bps; + recalled; + } + +let cash_rate_observation ~currency ~effective_at ~credit_rate_bps + ~debit_rate_bps = + if not (valid_currency currency) then + Error "cash rate currency must not be empty or contain whitespace" + else if not (valid_rate credit_rate_bps && valid_rate debit_rate_bps) then + Error "cash annual rate basis points must be between -1000000 and 1000000" + else Ok { currency; effective_at; credit_rate_bps; debit_rate_bps } + +let picoseconds_per_day = Z.of_string "86400000000000000" + +let span_picoseconds span = + let days, picoseconds = Ptime.Span.to_d_ps span in + Z.add (Z.mul (Z.of_int days) picoseconds_per_day) (Z.of_int64 picoseconds) + +let round_ratio numerator denominator = + let sign = Z.sign numerator in + if sign = 0 then Z.zero + else + let magnitude = Z.abs numerator in + let quotient, remainder = Z.ediv_rem magnitude denominator in + let rounded = + if Z.compare (Z.mul remainder (Z.of_int 2)) denominator >= 0 then + Z.succ quotient + else quotient + in + if sign < 0 then Z.neg rounded else rounded + +let year_days = function Actual_365 -> 365 | Actual_360 -> 360 + +let simple_micros policy ~principal_micros ~annual_rate_bps duration_ps = + let numerator = + Z.mul (Z.mul principal_micros (Z.of_int annual_rate_bps)) duration_ps + in + let denominator = + Z.mul + (Z.mul (Z.of_int 10_000) (Z.of_int (year_days policy.day_count))) + picoseconds_per_day + in + round_ratio numerator denominator + +let accrue policy ~principal ~annual_rate_bps span = + if not (valid_rate annual_rate_bps) then + Error "annual rate basis points must be between -1000000 and 1000000" + else + let duration_ps = span_picoseconds span in + if Z.sign duration_ps < 0 then + Error "financing accrual span must be nonnegative" + else + let principal_micros = Z.of_int64 (Scalar.Money.to_micros principal) in + let interest = + match policy.compounding with + | Simple -> + simple_micros policy ~principal_micros ~annual_rate_bps duration_ps + | Daily -> + let whole_days, remainder = + Z.ediv_rem duration_ps picoseconds_per_day + in + let rec compound remaining balance total = + if Z.equal remaining Z.zero then (balance, total) + else + let amount = + simple_micros policy ~principal_micros:balance + ~annual_rate_bps picoseconds_per_day + in + compound (Z.pred remaining) (Z.add balance amount) + (Z.add total amount) + in + let balance, full_interest = + compound whole_days principal_micros Z.zero + in + Z.add full_interest + (simple_micros policy ~principal_micros:balance ~annual_rate_bps + remainder) + in + if Z.fits_int64 interest then + Ok (Scalar.Money.of_micros (Z.to_int64 interest)) + else Error "financing accrual overflow" + +let day_count_to_string = function + | Actual_365 -> "actual_365" + | Actual_360 -> "actual_360" + +let compounding_to_string = function Simple -> "simple" | Daily -> "daily" +let missing_data_to_string = function Reject -> "reject" | Zero -> "zero" + +let locate_policy_to_string = function + | Reject_order -> "reject_order" + | Clip_fill -> "clip_fill" + +let recall_policy_to_string = function + | Reject_new_shorts -> "reject_new_shorts" + | Close_out -> "close_out" diff --git a/lib/financing.mli b/lib/financing.mli new file mode 100644 index 0000000..acb9c60 --- /dev/null +++ b/lib/financing.mli @@ -0,0 +1,71 @@ +(** Effective-time borrow availability and multi-currency financing policy. *) + +type day_count = Actual_365 | Actual_360 +type compounding = Simple | Daily +type missing_data = Reject | Zero +type locate_policy = Reject_order | Clip_fill +type recall_policy = Reject_new_shorts | Close_out + +type policy = private { + day_count : day_count; + compounding : compounding; + borrow_missing_data : missing_data; + cash_missing_data : missing_data; + locate_policy : locate_policy; + recall_policy : recall_policy; +} + +type borrow_observation = private { + instrument_id : Id.Instrument.t; + effective_at : Ptime.t; + available_quantity : Scalar.Quantity.t; + annual_rate_bps : int; + recalled : bool; +} + +type cash_rate_observation = private { + currency : string; + effective_at : Ptime.t; + credit_rate_bps : int; + debit_rate_bps : int; +} + +val policy : + day_count:day_count -> + compounding:compounding -> + borrow_missing_data:missing_data -> + cash_missing_data:missing_data -> + locate_policy:locate_policy -> + recall_policy:recall_policy -> + policy + +val borrow_observation : + instrument_id:Id.Instrument.t -> + effective_at:Ptime.t -> + available_quantity:Scalar.Quantity.t -> + annual_rate_bps:int -> + recalled:bool -> + (borrow_observation, string) result + +val cash_rate_observation : + currency:string -> + effective_at:Ptime.t -> + credit_rate_bps:int -> + debit_rate_bps:int -> + (cash_rate_observation, string) result + +val accrue : + policy -> + principal:Scalar.Money.t -> + annual_rate_bps:int -> + Ptime.Span.t -> + (Scalar.Money.t, string) result +(** [accrue] returns signed interest. Rounding is nearest micro-unit with ties + away from zero. Daily compounding rounds and capitalizes after every full + day, then accrues a simple fractional-day remainder. *) + +val day_count_to_string : day_count -> string +val compounding_to_string : compounding -> string +val missing_data_to_string : missing_data -> string +val locate_policy_to_string : locate_policy -> string +val recall_policy_to_string : recall_policy -> string diff --git a/lib/id.ml b/lib/id.ml index 5248232..b1eaf0f 100644 --- a/lib/id.ml +++ b/lib/id.ml @@ -48,3 +48,6 @@ module Fill = Make () module Strategy = Make () module Event = Make () module Corporate_action = Make () +module Venue = Make () +module Venue_calendar = Make () +module Risk_group = Make () diff --git a/lib/id.mli b/lib/id.mli index 67f0f05..ba0479e 100644 --- a/lib/id.mli +++ b/lib/id.mli @@ -21,3 +21,6 @@ module Fill : S module Strategy : S module Event : S module Corporate_action : S +module Venue : S +module Venue_calendar : S +module Risk_group : S diff --git a/lib/initial_portfolio.ml b/lib/initial_portfolio.ml new file mode 100644 index 0000000..5bf986d --- /dev/null +++ b/lib/initial_portfolio.ml @@ -0,0 +1,120 @@ +type position = { + instrument_id : Id.Instrument.t; + quantity : Scalar.Quantity.t; + cost_basis : Scalar.Money.t; + realized_pnl : Scalar.Money.t; + dividend_pnl : Scalar.Money.t; + execution_fees : Scalar.Money.t; + borrow_fees : Scalar.Money.t; +} + +type t = { + base_currency : string; + cash : (string * Scalar.Money.t) list; + positions : position list; + marks : (Id.Instrument.t * Scalar.Price.t) list; + fx_rates : (string * Scalar.Price.t) list; +} + +let ( let* ) result function_ = + match result with Ok value -> function_ value | Error _ as error -> error + +let valid_currency value = + String.length value > 0 + && String.for_all + (fun character -> + let code = Char.code character in + code >= 0x21 && code <> 0x7f) + value + +let position ~instrument_id ~quantity ~cost_basis ~realized_pnl ~dividend_pnl + ~execution_fees ~borrow_fees = + if Scalar.Quantity.is_zero quantity then + Error "initial position quantity must be nonzero" + else if + Scalar.Quantity.is_positive quantity + <> (Scalar.Money.compare cost_basis Scalar.Money.zero > 0) + then Error "initial position cost basis must have the same sign as quantity" + else if Scalar.Money.compare execution_fees Scalar.Money.zero < 0 then + Error "initial execution fees must be nonnegative" + else if Scalar.Money.compare borrow_fees Scalar.Money.zero < 0 then + Error "initial borrow fees must be nonnegative" + else + Ok + { + instrument_id; + quantity; + cost_basis; + realized_pnl; + dividend_pnl; + execution_fees; + borrow_fees; + } + +let unique compare values = + List.length values = List.length (List.sort_uniq compare values) + +let create ~base_currency ~cash ~positions ~marks ~fx_rates = + if not (valid_currency base_currency) then + Error "base currency must not be empty or contain whitespace" + else if cash = [] then Error "initial cash must contain at least one currency" + else if not (List.for_all (fun (currency, _) -> valid_currency currency) cash) + then Error "cash currency must not be empty or contain whitespace" + else if not (unique String.compare (List.map fst cash)) then + Error "initial cash currencies must be unique" + else if not (List.mem_assoc base_currency cash) then + Error "initial cash must include the base currency" + else + let position_ids = List.map (fun value -> value.instrument_id) positions in + let mark_ids = List.map fst marks in + let fx_currencies = List.map fst fx_rates in + if not (unique Id.Instrument.compare position_ids) then + Error "initial position instrument IDs must be unique" + else if not (unique Id.Instrument.compare mark_ids) then + Error "initial mark instrument IDs must be unique" + else if + List.sort Id.Instrument.compare position_ids + <> List.sort Id.Instrument.compare mark_ids + then Error "initial marks must cover every initial position exactly once" + else if not (unique String.compare fx_currencies) then + Error "initial FX currencies must be unique" + else if + List.sort String.compare (List.map fst cash) + <> List.sort String.compare fx_currencies + then Error "initial FX rates must cover every cash currency exactly once" + else + match List.assoc_opt base_currency fx_rates with + | None -> Error "initial FX rates must include the base currency" + | Some rate -> + let one = Scalar.Price.of_decimal_string "1" |> Result.get_ok in + if Scalar.Price.compare rate one <> 0 then + Error "initial base-currency FX rate must equal one" + else + Ok + { + base_currency; + cash = + List.sort + (fun (left, _) (right, _) -> String.compare left right) + cash; + positions = + List.sort + (fun left right -> + Id.Instrument.compare left.instrument_id + right.instrument_id) + positions; + marks = + List.sort + (fun (left, _) (right, _) -> + Id.Instrument.compare left right) + marks; + fx_rates = + List.sort + (fun (left, _) (right, _) -> String.compare left right) + fx_rates; + } + +let cash_only ~base_currency ~cash = + let one = Scalar.Price.of_decimal_string "1" |> Result.get_ok in + let fx_rates = List.map (fun (currency, _) -> (currency, one)) cash in + create ~base_currency ~cash ~positions:[] ~marks:[] ~fx_rates diff --git a/lib/initial_portfolio.mli b/lib/initial_portfolio.mli new file mode 100644 index 0000000..2143ec8 --- /dev/null +++ b/lib/initial_portfolio.mli @@ -0,0 +1,42 @@ +(** Immutable point-in-time portfolio state used to start a replay. *) + +type position = private { + instrument_id : Id.Instrument.t; + quantity : Scalar.Quantity.t; + cost_basis : Scalar.Money.t; + realized_pnl : Scalar.Money.t; + dividend_pnl : Scalar.Money.t; + execution_fees : Scalar.Money.t; + borrow_fees : Scalar.Money.t; +} + +type t = private { + base_currency : string; + cash : (string * Scalar.Money.t) list; + positions : position list; + marks : (Id.Instrument.t * Scalar.Price.t) list; + fx_rates : (string * Scalar.Price.t) list; +} + +val position : + instrument_id:Id.Instrument.t -> + quantity:Scalar.Quantity.t -> + cost_basis:Scalar.Money.t -> + realized_pnl:Scalar.Money.t -> + dividend_pnl:Scalar.Money.t -> + execution_fees:Scalar.Money.t -> + borrow_fees:Scalar.Money.t -> + (position, string) result + +val create : + base_currency:string -> + cash:(string * Scalar.Money.t) list -> + positions:position list -> + marks:(Id.Instrument.t * Scalar.Price.t) list -> + fx_rates:(string * Scalar.Price.t) list -> + (t, string) result + +val cash_only : + base_currency:string -> + cash:(string * Scalar.Money.t) list -> + (t, string) result 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/journal.ml b/lib/journal.ml index e7a9e84..f7946aa 100644 --- a/lib/journal.ml +++ b/lib/journal.ml @@ -1,58 +1,30 @@ -type t = { - final_path : string; - partial_path : string; - channel : out_channel; - mutable closed : bool; -} +type t = { artifact : Artifact_writer.t } -let create final_path = - let partial_path = final_path ^ ".partial" in - if Sys.file_exists final_path then - Error ("journal already exists: " ^ final_path) - else if Sys.file_exists partial_path then - Error ("partial journal already exists: " ^ partial_path) - else - try - let channel = - open_out_gen - [ Open_wronly; Open_creat; Open_excl; Open_binary ] - 0o600 partial_path - in - Ok { final_path; partial_path; channel; closed = false } - with Sys_error message -> Error ("could not create journal: " ^ message) +let audit_context event = + let event_id = Id.Event.to_string event.Audit.event_id in + let causation_ids = List.map Id.Event.to_string event.causation_ids in + let order_id = + match event.event with + | Audit.Order_accepted order | Order_rejected order -> + Some (Id.Order.to_string order.Order.id) + | Order_cancelled { order; _ } -> Some (Id.Order.to_string order.id) + | Fill_applied fill -> Some (Id.Order.to_string fill.Fill.order_id) + | Fill_clipped { order_id; _ } -> Some (Id.Order.to_string order_id) + | _ -> None + in + (event_id, order_id, causation_ids) + +let create ?effects ?durability final_path = + Artifact_writer.create ?effects ?durability ~label:"journal" final_path + |> Result.map (fun artifact -> { artifact }) let append journal event = - if journal.closed then Error "cannot append to a closed journal" - else - try - output_string journal.channel (Codec.audit_to_string event); - output_char journal.channel '\n'; - flush journal.channel; - Ok () - with Sys_error message -> - Error ("could not append journal " ^ journal.partial_path ^ ": " ^ message) + let event_id, order_id, causation_ids = audit_context event in + Artifact_writer.append journal.artifact (Codec.audit_to_string event ^ "\n") + |> Result.map_error (Diagnostic.annotate ~event_id ?order_id ~causation_ids) let close_preserving_partial journal = - if not journal.closed then ( - journal.closed <- true; - close_out_noerr journal.channel) + Artifact_writer.close_preserving_partial journal.artifact -let commit journal = - if journal.closed then Error "cannot commit a closed journal" - else - try - flush journal.channel; - close_out journal.channel; - journal.closed <- true; - Unix.link journal.partial_path journal.final_path; - Unix.unlink journal.partial_path; - Ok () - with - | Sys_error message -> - close_preserving_partial journal; - Error ("could not finalize journal: " ^ message) - | Unix.Unix_error (code, operation, target) -> - close_preserving_partial journal; - Error - (Printf.sprintf "could not finalize journal: %s(%s): %s" operation - target (Unix.error_message code)) +let commit journal = Artifact_writer.commit [ journal.artifact ] +let artifact journal = journal.artifact diff --git a/lib/journal.mli b/lib/journal.mli index 29b58c2..cacb854 100644 --- a/lib/journal.mli +++ b/lib/journal.mli @@ -2,7 +2,13 @@ type t -val create : string -> (t, string) result -val append : t -> Audit.t -> (unit, string) result +val create : + ?effects:Boundary_effects.t -> + ?durability:Artifact_writer.durability -> + string -> + (t, Diagnostic.t) result + +val append : t -> Audit.t -> (unit, Diagnostic.t) result val close_preserving_partial : t -> unit -val commit : t -> (unit, string) result +val commit : t -> (unit, Diagnostic.t) result +val artifact : t -> Artifact_writer.t diff --git a/lib/market_event.ml b/lib/market_event.ml new file mode 100644 index 0000000..294a4b7 --- /dev/null +++ b/lib/market_event.ml @@ -0,0 +1,92 @@ +type aggressor_side = Buy | Sell | Unknown + +type kind = + | Quote of { + bid_price : Scalar.Price.t; + bid_quantity : Scalar.Quantity.t; + ask_price : Scalar.Price.t; + ask_quantity : Scalar.Quantity.t; + } + | Trade of { + price : Scalar.Price.t; + quantity : Scalar.Quantity.t; + aggressor_side : aggressor_side; + } + +type t = { + instrument_id : Id.Instrument.t; + event_at : Ptime.t; + available_at : Ptime.t; + received_at : Ptime.t; + ingest_sequence : int64; + kind : kind; +} + +let validate_common ~event_at ~available_at ~received_at ~ingest_sequence = + if Int64.compare ingest_sequence 0L <= 0 then + Error "market event ingest sequence must be positive" + else if Ptime.compare available_at event_at < 0 then + Error "market event availability must not precede event time" + else if Ptime.compare received_at available_at < 0 then + Error "market event receipt must not precede availability" + else Ok () + +let quote ~instrument_id ~event_at ~available_at ~received_at ~ingest_sequence + ~bid_price ~bid_quantity ~ask_price ~ask_quantity = + let ( let* ) result function_ = Result.bind result function_ in + let* () = + validate_common ~event_at ~available_at ~received_at ~ingest_sequence + in + if Scalar.Price.compare bid_price ask_price >= 0 then + Error "quote bid price must be below ask price" + else if + Scalar.Quantity.is_zero bid_quantity || Scalar.Quantity.is_zero ask_quantity + then Error "quote quantities must be positive" + else + Ok + { + instrument_id; + event_at; + available_at; + received_at; + ingest_sequence; + kind = Quote { bid_price; bid_quantity; ask_price; ask_quantity }; + } + +let trade ~instrument_id ~event_at ~available_at ~received_at ~ingest_sequence + ~price ~quantity ~aggressor_side = + let ( let* ) result function_ = Result.bind result function_ in + let* () = + validate_common ~event_at ~available_at ~received_at ~ingest_sequence + in + if Scalar.Quantity.is_zero quantity then + Error "trade quantity must be positive" + else + Ok + { + instrument_id; + event_at; + available_at; + received_at; + ingest_sequence; + kind = Trade { price; quantity; aggressor_side }; + } + +let compare_replay_order left right = + let availability = Ptime.compare left.available_at right.available_at in + if availability <> 0 then availability + else + let receipt = Ptime.compare left.received_at right.received_at in + if receipt <> 0 then receipt + else Int64.compare left.ingest_sequence right.ingest_sequence + +let aggressor_side_to_string = function + | Buy -> "buy" + | Sell -> "sell" + | Unknown -> "unknown" + +let aggressor_side_of_string = function + | "buy" -> Ok Buy + | "sell" -> Ok Sell + | "unknown" -> Ok Unknown + | _ -> Error "trade aggressor_side must be buy, sell, or unknown" diff --git a/lib/market_event.mli b/lib/market_event.mli new file mode 100644 index 0000000..d8a3287 --- /dev/null +++ b/lib/market_event.mli @@ -0,0 +1,52 @@ +(** Causally observable quote and trade events. *) + +type aggressor_side = Buy | Sell | Unknown + +type kind = + | Quote of { + bid_price : Scalar.Price.t; + bid_quantity : Scalar.Quantity.t; + ask_price : Scalar.Price.t; + ask_quantity : Scalar.Quantity.t; + } + | Trade of { + price : Scalar.Price.t; + quantity : Scalar.Quantity.t; + aggressor_side : aggressor_side; + } + +type t = private { + instrument_id : Id.Instrument.t; + event_at : Ptime.t; + available_at : Ptime.t; + received_at : Ptime.t; + ingest_sequence : int64; + kind : kind; +} + +val quote : + instrument_id:Id.Instrument.t -> + event_at:Ptime.t -> + available_at:Ptime.t -> + received_at:Ptime.t -> + ingest_sequence:int64 -> + bid_price:Scalar.Price.t -> + bid_quantity:Scalar.Quantity.t -> + ask_price:Scalar.Price.t -> + ask_quantity:Scalar.Quantity.t -> + (t, string) result + +val trade : + instrument_id:Id.Instrument.t -> + event_at:Ptime.t -> + available_at:Ptime.t -> + received_at:Ptime.t -> + ingest_sequence:int64 -> + price:Scalar.Price.t -> + quantity:Scalar.Quantity.t -> + aggressor_side:aggressor_side -> + (t, string) result + +val compare_replay_order : t -> t -> int +val aggressor_side_to_string : aggressor_side -> string +val aggressor_side_of_string : string -> (aggressor_side, string) result diff --git a/lib/market_slice.ml b/lib/market_slice.ml index 8281565..062b1e7 100644 --- a/lib/market_slice.ml +++ b/lib/market_slice.ml @@ -7,8 +7,14 @@ type t = { available_at : Ptime.t; received_at : Ptime.t; bars : Bar.t list; + market_events : Market_event.t list; + order_book_events : Order_book_event.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; } let valid_currency value = @@ -28,7 +34,8 @@ let compare_bar left right = Id.Instrument.compare left.Bar.instrument_id right.Bar.instrument_id let create ~slice_sequence ~start_at ~end_at ~available_at ~received_at ~bars - ~fx_rates ~corporate_actions = + ~fx_rates ~corporate_actions ~borrow_observations ~cash_rate_observations + ~settlement_failures ~lifecycle_events ~market_events ~order_book_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 @@ -60,12 +67,82 @@ let create ~slice_sequence ~start_at ~end_at ~available_at ~received_at ~bars 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) -> (not (Id.Corporate_action.equal left.Corporate_action.id right.id)) && unique_actions remaining in + let borrow_observations = + List.sort + (fun (left : Financing.borrow_observation) + (right : Financing.borrow_observation) -> + Id.Instrument.compare left.Financing.instrument_id + right.Financing.instrument_id) + borrow_observations + in + let rec unique_borrow = function + | [] | [ _ ] -> true + | left :: (right :: _ as remaining) -> + (not + (Id.Instrument.equal left.Financing.instrument_id + right.Financing.instrument_id)) + && unique_borrow remaining + in + let cash_rate_observations = + List.sort + (fun (left : Financing.cash_rate_observation) + (right : Financing.cash_rate_observation) -> + String.compare left.Financing.currency right.Financing.currency) + cash_rate_observations + in + let rec unique_cash_rate = function + | [] | [ _ ] -> true + | left :: (right :: _ as remaining) -> + (not (String.equal left.Financing.currency right.Financing.currency)) + && unique_cash_rate remaining + in + let settlement_failures = + List.sort + (fun (left : Settlement.failure) (right : Settlement.failure) -> + String.compare left.instruction_id right.instruction_id) + settlement_failures + in + let rec unique_failure = function + | [] | [ _ ] -> true + | left :: (right :: _ as remaining) -> + (not + (String.equal left.Settlement.instruction_id right.instruction_id)) + && unique_failure remaining + in + let rec ordered_events = function + | [] | [ _ ] -> true + | left :: (right :: _ as remaining) -> + Market_event.compare_replay_order left right < 0 + && Int64.compare left.Market_event.ingest_sequence + right.Market_event.ingest_sequence + < 0 + && ordered_events remaining + in + let rec ordered_book_events = function + | [] | [ _ ] -> true + | left :: (right :: _ as remaining) -> + Order_book_event.compare_replay_order left right < 0 + && Int64.compare left.Order_book_event.ingest_sequence + right.Order_book_event.ingest_sequence + < 0 + && ordered_book_events remaining + in if not (unique bars) then Error "market slice must contain one bar per instrument" else if fx_rates = [] then Error "market slice must contain FX rates" @@ -73,6 +150,22 @@ let create ~slice_sequence ~start_at ~end_at ~available_at ~received_at ~bars 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 + Error "market slice cash rate currencies must be unique" + else if not (unique_failure settlement_failures) then + Error "market slice settlement failure instruction IDs must be unique" + else if not (ordered_events market_events) then + Error + "market events must be strictly ordered by availability, receipt, and \ + ingest sequence" + else if not (ordered_book_events order_book_events) then + Error + "order-book events must be strictly ordered by availability, receipt, \ + and ingest sequence" else Ok { @@ -82,8 +175,14 @@ let create ~slice_sequence ~start_at ~end_at ~available_at ~received_at ~bars available_at; received_at; bars; + market_events; + order_book_events; fx_rates; corporate_actions; + lifecycle_events; + borrow_observations; + cash_rate_observations; + settlement_failures; } let bar state instrument_id = @@ -101,7 +200,15 @@ let compare_replay_order left right = else Int64.compare left.slice_sequence right.slice_sequence let pp formatter state = - Format.fprintf formatter "slice[%Ld] bars=%d fx=%d actions=%d" + Format.fprintf formatter + "slice[%Ld] bars=%d events=%d book_events=%d fx=%d actions=%d lifecycle=%d \ + borrow=%d cash_rates=%d failures=%d" state.slice_sequence (List.length state.bars) + (List.length state.market_events) + (List.length state.order_book_events) (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 34cf4b5..8f4eee7 100644 --- a/lib/market_slice.mli +++ b/lib/market_slice.mli @@ -12,8 +12,14 @@ type t = private { available_at : Ptime.t; received_at : Ptime.t; bars : Bar.t list; + market_events : Market_event.t list; + order_book_events : Order_book_event.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; } val create : @@ -25,6 +31,12 @@ val create : 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 -> + market_events:Market_event.t list -> + order_book_events:Order_book_event.t list -> (t, string) result val bar : t -> Id.Instrument.t -> Bar.t option diff --git a/lib/metric.ml b/lib/metric.ml new file mode 100644 index 0000000..08e086e --- /dev/null +++ b/lib/metric.ml @@ -0,0 +1,101 @@ +type numeric = string +type value = Numeric of numeric | String of string | Boolean of bool +type aggregation = Last | Sum | Minimum | Maximum | Mean +type dimension = { key : string; value : string } + +type t = { + name : string; + value : value; + unit_ : string option; + dimensions : dimension list; + aggregation : aggregation option; +} + +let max_name_bytes = Resource_limits.metric_name_bytes +let max_string_value_bytes = Resource_limits.metric_string_value_bytes +let max_unit_bytes = Resource_limits.metric_unit_bytes +let max_dimensions = Resource_limits.metric_dimensions +let max_dimension_key_bytes = Resource_limits.metric_dimension_key_bytes +let max_dimension_value_bytes = Resource_limits.metric_dimension_value_bytes + +let valid_trimmed ~maximum value = + String.length value > 0 + && String.length value <= maximum + && String.equal value (String.trim value) + +let numeric_to_string value = value + +let numeric_of_string value = + let length = String.length value in + let start = if length > 0 && value.[0] = '-' then 1 else 0 in + let decimal = ref None in + let valid = ref (start < length) in + for index = start to length - 1 do + match value.[index] with + | '0' .. '9' -> () + | '.' when !decimal = None && index > start && index + 1 < length -> + decimal := Some index + | _ -> valid := false + done; + let integer_end = Option.value !decimal ~default:length in + if integer_end - start > 1 && value.[start] = '0' then valid := false; + if Option.is_some !decimal && value.[length - 1] = '0' then valid := false; + if String.equal value "-0" then valid := false; + if !valid then Ok value + else Error "metric numeric value must be a canonical decimal string" + +let aggregation_to_string = function + | Last -> "last" + | Sum -> "sum" + | Minimum -> "minimum" + | Maximum -> "maximum" + | Mean -> "mean" + +let aggregation_of_string = function + | "last" -> Ok Last + | "sum" -> Ok Sum + | "minimum" -> Ok Minimum + | "maximum" -> Ok Maximum + | "mean" -> Ok Mean + | _ -> Error "metric aggregation must be last, sum, minimum, maximum, or mean" + +let create ~name ~value ?unit_ ?(dimensions = []) ?aggregation () = + if not (valid_trimmed ~maximum:max_name_bytes name) then + Error "metric name must be a nonempty trimmed string of at most 128 bytes" + else if + match value with + | String value -> String.length value > max_string_value_bytes + | Numeric _ | Boolean _ -> false + then Error "metric string value must contain at most 1024 bytes" + else if + match (value, aggregation) with + | (String _ | Boolean _), Some (Sum | Minimum | Maximum | Mean) -> true + | _ -> false + then Error "non-numeric metrics only support last aggregation" + else if + match unit_ with + | Some unit_ -> not (valid_trimmed ~maximum:max_unit_bytes unit_) + | None -> false + then Error "metric unit must be a nonempty trimmed string of at most 64 bytes" + else if List.length dimensions > max_dimensions then + Error "metric dimensions must contain at most 16 entries" + else + let dimensions = + List.sort + (fun (left, _) (right, _) -> String.compare left right) + dimensions + in + let rec validate prior acc = function + | [] -> Ok { name; value; unit_; dimensions = List.rev acc; aggregation } + | (key, value) :: remaining -> + if not (valid_trimmed ~maximum:max_dimension_key_bytes key) then + Error + "metric dimension key must be a nonempty trimmed string of at \ + most 64 bytes" + else if String.length value > max_dimension_value_bytes then + Error "metric dimension value must contain at most 128 bytes" + else if Option.equal String.equal prior (Some key) then + Error "metric dimension keys must be unique" + else validate (Some key) ({ key; value } :: acc) remaining + in + validate None [] dimensions diff --git a/lib/metric.mli b/lib/metric.mli new file mode 100644 index 0000000..a529fae --- /dev/null +++ b/lib/metric.mli @@ -0,0 +1,28 @@ +(** Typed, dimensioned strategy observations. *) + +type numeric +type value = Numeric of numeric | String of string | Boolean of bool +type aggregation = Last | Sum | Minimum | Maximum | Mean +type dimension = { key : string; value : string } + +type t = private { + name : string; + value : value; + unit_ : string option; + dimensions : dimension list; + aggregation : aggregation option; +} + +val create : + name:string -> + value:value -> + ?unit_:string -> + ?dimensions:(string * string) list -> + ?aggregation:aggregation -> + unit -> + (t, string) result + +val aggregation_to_string : aggregation -> string +val aggregation_of_string : string -> (aggregation, string) result +val numeric_of_string : string -> (numeric, string) result +val numeric_to_string : numeric -> string diff --git a/lib/oms.ml b/lib/oms.ml index 7a6f495..6b4c8ab 100644 --- a/lib/oms.ml +++ b/lib/oms.ml @@ -58,6 +58,18 @@ let cancel state order_id = | Error _ as error -> error | Ok cancelled -> Ok (insert state cancelled, cancelled)) +let trigger state order_id ~updated_event_id ~triggered_at + ~triggered_slice_sequence = + match find state order_id with + | None -> Error "cannot trigger an unknown order" + | Some order -> ( + match + Order.trigger order ~updated_event_id ~triggered_at + ~triggered_slice_sequence + with + | Error _ as error -> error + | Ok triggered -> Ok (insert state triggered, triggered)) + let adjust_for_split state ~instrument_id ~updated_event_ids ~numerator ~denominator = let active = active_for_instrument state instrument_id in diff --git a/lib/oms.mli b/lib/oms.mli index 07f4295..a52c464 100644 --- a/lib/oms.mli +++ b/lib/oms.mli @@ -32,6 +32,14 @@ val reject : val cancel : t -> Id.Order.t -> (t * Order.t, string) result +val trigger : + t -> + Id.Order.t -> + updated_event_id:Id.Event.t -> + triggered_at:Ptime.t -> + triggered_slice_sequence:int64 -> + (t * Order.t, string) result + val adjust_for_split : t -> instrument_id:Id.Instrument.t -> diff --git a/lib/order.ml b/lib/order.ml index 3b54230..6698210 100644 --- a/lib/order.ml +++ b/lib/order.ml @@ -1,15 +1,36 @@ type side = Buy | Sell -type kind = Market | Limit of Scalar.Price.t -type origin = Direct | Target_rebalance | Margin_liquidation + +type kind = + | Market + | Limit of Scalar.Price.t + | Stop of Scalar.Price.t + | Stop_limit of { + trigger_price : Scalar.Price.t; + limit_price : Scalar.Price.t; + } + +type time_in_force = + | Gtc + | Ioc + | Fok + | Day of { venue_id : Id.Venue.t; calendar_id : Id.Venue_calendar.t } + | Gtd of Ptime.t + +type origin = Direct | Target_rebalance | Margin_liquidation | Borrow_recall type request = { instrument_id : Id.Instrument.t; side : side; quantity : Scalar.Quantity.t; kind : kind; + time_in_force : time_in_force; origin : origin; } +type trigger_state = + | Dormant + | Triggered of { triggered_at : Ptime.t; triggered_slice_sequence : int64 } + type status = | Working | Partially_filled @@ -30,13 +51,28 @@ type t = { eligible_after_slice_sequence : int64; filled_quantity : Scalar.Quantity.t; filled_notional : Scalar.Money.t; + trigger_state : trigger_state option; status : status; } -let request ~instrument_id ~side ~quantity ~kind ~origin = +let default_time_in_force = function Market -> Ioc | _ -> Gtc + +let valid_stop_limit side trigger_price limit_price = + match side with + | Buy -> Scalar.Price.compare limit_price trigger_price >= 0 + | Sell -> Scalar.Price.compare limit_price trigger_price <= 0 + +let request ~instrument_id ~side ~quantity ~kind ~time_in_force ~origin = if not (Scalar.Quantity.is_positive quantity) then Error "order quantity must be positive" - else Ok { instrument_id; side; quantity; kind; origin } + else + match kind with + | Stop_limit { trigger_price; limit_price } + when not (valid_stop_limit side trigger_price limit_price) -> + Error + "buy stop-limit prices require limit >= trigger and sell stop-limit \ + prices require limit <= trigger" + | _ -> Ok { instrument_id; side; quantity; kind; time_in_force; origin } let make ~id ~created_event_id ~sequence ~created_at ~eligible_after_slice_sequence ~request ~status = @@ -44,6 +80,11 @@ let make ~id ~created_event_id ~sequence ~created_at Error "order sequence must be nonnegative" else if Int64.compare eligible_after_slice_sequence 0L < 0 then Error "order eligibility sequence must be nonnegative" + else if + match request.time_in_force with + | Gtd expires_at -> Ptime.compare expires_at created_at <= 0 + | Gtc | Ioc | Fok | Day _ -> false + then Error "GTD expiry must follow order creation" else Ok { @@ -56,6 +97,10 @@ let make ~id ~created_event_id ~sequence ~created_at eligible_after_slice_sequence; filled_quantity = Scalar.Quantity.zero; filled_notional = Scalar.Money.zero; + trigger_state = + (match request.kind with + | Stop _ | Stop_limit _ -> Some Dormant + | Market | Limit _ -> None); status; } @@ -88,7 +133,47 @@ let is_active order = let is_terminal order = not (is_active order) let is_market order = - match order.request.kind with Market -> true | Limit _ -> false + match order.request.kind with + | Market -> true + | Limit _ | Stop _ | Stop_limit _ -> false + +let is_ioc order = + match order.request.time_in_force with Ioc | Fok -> true | _ -> false + +let is_fok order = + match order.request.time_in_force with Fok -> true | _ -> false + +let is_dormant_stop order = + match order.trigger_state with + | Some Dormant -> true + | Some (Triggered _) | None -> false + +let trigger order ~updated_event_id ~triggered_at ~triggered_slice_sequence = + if not (is_active order) then Error "cannot trigger a terminal order" + else + match order.trigger_state with + | None -> Error "cannot trigger an unconditional order" + | Some (Triggered _) -> Error "cannot trigger an already-triggered order" + | Some Dormant -> + if Int64.compare triggered_slice_sequence 0L < 0 then + Error "trigger slice sequence must be nonnegative" + else + Ok + { + order with + updated_event_id; + trigger_state = + Some (Triggered { triggered_at; triggered_slice_sequence }); + } + +let effective_kind order = + match (order.request.kind, order.trigger_state) with + | ((Market | Limit _) as kind), _ -> Some kind + | Stop _, Some Dormant | Stop_limit _, Some Dormant -> None + | Stop _, Some (Triggered _) -> Some Market + | Stop_limit { limit_price; _ }, Some (Triggered _) -> + Some (Limit limit_price) + | (Stop _ | Stop_limit _), None -> None let apply_fill order ~quantity ~notional = if not (is_active order) then Error "cannot fill a terminal order" @@ -131,6 +216,20 @@ let adjust_for_split order ~updated_event_id ~numerator ~denominator = Scalar.Price.scale_ratio_exact price ~numerator:denominator ~denominator:numerator |> Result.map (fun price -> Limit price) + | Stop trigger_price -> + Scalar.Price.scale_ratio_exact trigger_price ~numerator:denominator + ~denominator:numerator + |> Result.map (fun price -> Stop price) + | Stop_limit { trigger_price; limit_price } -> + let* trigger_price = + Scalar.Price.scale_ratio_exact trigger_price ~numerator:denominator + ~denominator:numerator + in + let* limit_price = + Scalar.Price.scale_ratio_exact limit_price ~numerator:denominator + ~denominator:numerator + in + Ok (Stop_limit { trigger_price; limit_price }) in if not (Scalar.Quantity.is_positive quantity) then Error "split-adjusted order quantity must be positive" @@ -148,11 +247,28 @@ let side_to_string = function Buy -> "buy" | Sell -> "sell" let kind_to_string = function | Market -> "market" | Limit price -> "limit@" ^ Scalar.Price.to_decimal_string price + | Stop price -> "stop@" ^ Scalar.Price.to_decimal_string price + | Stop_limit { trigger_price; limit_price } -> + "stop_limit@" + ^ Scalar.Price.to_decimal_string trigger_price + ^ "/" + ^ Scalar.Price.to_decimal_string limit_price + +let time_in_force_to_string = function + | Gtc -> "gtc" + | Ioc -> "ioc" + | Fok -> "fok" + | Day { venue_id; calendar_id } -> + Printf.sprintf "day@%s/%s" + (Id.Venue.to_string venue_id) + (Id.Venue_calendar.to_string calendar_id) + | Gtd expires_at -> "gtd@" ^ Ptime.to_rfc3339 expires_at let origin_to_string = function | Direct -> "direct" | Target_rebalance -> "target_rebalance" | Margin_liquidation -> "margin_liquidation" + | Borrow_recall -> "borrow_recall" let status_to_string = function | Working -> "working" diff --git a/lib/order.mli b/lib/order.mli index dffa9d4..5f6bf0f 100644 --- a/lib/order.mli +++ b/lib/order.mli @@ -1,17 +1,38 @@ (** Immutable orders and their legal state transitions. *) type side = Buy | Sell -type kind = Market | Limit of Scalar.Price.t -type origin = Direct | Target_rebalance | Margin_liquidation + +type kind = + | Market + | Limit of Scalar.Price.t + | Stop of Scalar.Price.t + | Stop_limit of { + trigger_price : Scalar.Price.t; + limit_price : Scalar.Price.t; + } + +type time_in_force = + | Gtc + | Ioc + | Fok + | Day of { venue_id : Id.Venue.t; calendar_id : Id.Venue_calendar.t } + | Gtd of Ptime.t + +type origin = Direct | Target_rebalance | Margin_liquidation | Borrow_recall type request = private { instrument_id : Id.Instrument.t; side : side; quantity : Scalar.Quantity.t; kind : kind; + time_in_force : time_in_force; origin : origin; } +type trigger_state = + | Dormant + | Triggered of { triggered_at : Ptime.t; triggered_slice_sequence : int64 } + type status = | Working | Partially_filled @@ -29,14 +50,18 @@ type t = private { eligible_after_slice_sequence : int64; filled_quantity : Scalar.Quantity.t; filled_notional : Scalar.Money.t; + trigger_state : trigger_state option; status : status; } +val default_time_in_force : kind -> time_in_force + val request : instrument_id:Id.Instrument.t -> side:side -> quantity:Scalar.Quantity.t -> kind:kind -> + time_in_force:time_in_force -> origin:origin -> (request, string) result @@ -63,6 +88,17 @@ val remaining_quantity : t -> Scalar.Quantity.t val is_active : t -> bool val is_terminal : t -> bool val is_market : t -> bool +val is_ioc : t -> bool +val is_fok : t -> bool +val is_dormant_stop : t -> bool +val effective_kind : t -> kind option + +val trigger : + t -> + updated_event_id:Id.Event.t -> + triggered_at:Ptime.t -> + triggered_slice_sequence:int64 -> + (t, string) result val apply_fill : t -> @@ -81,6 +117,7 @@ val adjust_for_split : val side_to_string : side -> string val kind_to_string : kind -> string +val time_in_force_to_string : time_in_force -> string val origin_to_string : origin -> string val status_to_string : status -> string val pp : Format.formatter -> t -> unit diff --git a/lib/order_book_event.ml b/lib/order_book_event.ml new file mode 100644 index 0000000..701c837 --- /dev/null +++ b/lib/order_book_event.ml @@ -0,0 +1,132 @@ +type side = Bid | Ask +type level = { price : Scalar.Price.t; quantity : Scalar.Quantity.t } + +type kind = + | Snapshot of { bids : level list; asks : level list } + | Set of { side : side; price : Scalar.Price.t; quantity : Scalar.Quantity.t } + | Delete of { side : side; price : Scalar.Price.t } + | Trade of { + price : Scalar.Price.t; + quantity : Scalar.Quantity.t; + aggressor_side : Market_event.aggressor_side; + } + +type t = { + instrument_id : Id.Instrument.t; + event_at : Ptime.t; + available_at : Ptime.t; + received_at : Ptime.t; + ingest_sequence : int64; + book_sequence : int64; + kind : kind; +} + +let level ~price ~quantity = + if Scalar.Quantity.is_zero quantity then + Error "order-book level quantity must be positive" + else Ok { price; quantity } + +let validate_common ~event_at ~available_at ~received_at ~ingest_sequence + ~book_sequence = + if Int64.compare ingest_sequence 0L <= 0 then + Error "order-book ingest sequence must be positive" + else if Int64.compare book_sequence 0L <= 0 then + Error "order-book sequence must be positive" + else if Ptime.compare available_at event_at < 0 then + Error "order-book availability must not precede event time" + else if Ptime.compare received_at available_at < 0 then + Error "order-book receipt must not precede availability" + else Ok () + +let ordered_levels side levels = + let rec ordered = function + | [] | [ _ ] -> true + | left :: (right :: _ as remaining) -> + let comparison = Scalar.Price.compare left.price right.price in + (match side with Bid -> comparison > 0 | Ask -> comparison < 0) + && ordered remaining + in + ordered levels + +let snapshot ~instrument_id ~event_at ~available_at ~received_at + ~ingest_sequence ~book_sequence ~bids ~asks = + let ( let* ) = Result.bind in + let* () = + validate_common ~event_at ~available_at ~received_at ~ingest_sequence + ~book_sequence + in + if bids = [] || asks = [] then + Error "order-book snapshot must contain bid and ask depth" + else if not (ordered_levels Bid bids && ordered_levels Ask asks) then + Error "order-book snapshot levels must be unique and price ordered" + else if Scalar.Price.compare (List.hd bids).price (List.hd asks).price > 0 + then Error "crossed order-book snapshot is invalid" + else + Ok + { + instrument_id; + event_at; + available_at; + received_at; + ingest_sequence; + book_sequence; + kind = Snapshot { bids; asks }; + } + +let create_change kind ~instrument_id ~event_at ~available_at ~received_at + ~ingest_sequence ~book_sequence = + Result.map + (fun () -> + { + instrument_id; + event_at; + available_at; + received_at; + ingest_sequence; + book_sequence; + kind; + }) + (validate_common ~event_at ~available_at ~received_at ~ingest_sequence + ~book_sequence) + +let set ~instrument_id ~event_at ~available_at ~received_at ~ingest_sequence + ~book_sequence ~side ~price ~quantity = + if Scalar.Quantity.is_zero quantity then + Error "order-book set quantity must be positive" + else + create_change + (Set { side; price; quantity }) + ~instrument_id ~event_at ~available_at ~received_at ~ingest_sequence + ~book_sequence + +let delete ~instrument_id ~event_at ~available_at ~received_at ~ingest_sequence + ~book_sequence ~side ~price = + create_change + (Delete { side; price }) + ~instrument_id ~event_at ~available_at ~received_at ~ingest_sequence + ~book_sequence + +let trade ~instrument_id ~event_at ~available_at ~received_at ~ingest_sequence + ~book_sequence ~price ~quantity ~aggressor_side = + if Scalar.Quantity.is_zero quantity then + Error "order-book trade quantity must be positive" + else + create_change + (Trade { price; quantity; aggressor_side }) + ~instrument_id ~event_at ~available_at ~received_at ~ingest_sequence + ~book_sequence + +let compare_replay_order left right = + let availability = Ptime.compare left.available_at right.available_at in + if availability <> 0 then availability + else + let receipt = Ptime.compare left.received_at right.received_at in + if receipt <> 0 then receipt + else Int64.compare left.ingest_sequence right.ingest_sequence + +let side_to_string = function Bid -> "bid" | Ask -> "ask" + +let side_of_string = function + | "bid" -> Ok Bid + | "ask" -> Ok Ask + | _ -> Error "order-book side must be bid or ask" diff --git a/lib/order_book_event.mli b/lib/order_book_event.mli new file mode 100644 index 0000000..4fc6eb3 --- /dev/null +++ b/lib/order_book_event.mli @@ -0,0 +1,77 @@ +(** Causally ordered level-two order-book observations. *) + +type side = Bid | Ask +type level = private { price : Scalar.Price.t; quantity : Scalar.Quantity.t } + +type kind = + | Snapshot of { bids : level list; asks : level list } + | Set of { side : side; price : Scalar.Price.t; quantity : Scalar.Quantity.t } + | Delete of { side : side; price : Scalar.Price.t } + | Trade of { + price : Scalar.Price.t; + quantity : Scalar.Quantity.t; + aggressor_side : Market_event.aggressor_side; + } + +type t = private { + instrument_id : Id.Instrument.t; + event_at : Ptime.t; + available_at : Ptime.t; + received_at : Ptime.t; + ingest_sequence : int64; + book_sequence : int64; + kind : kind; +} + +val level : + price:Scalar.Price.t -> quantity:Scalar.Quantity.t -> (level, string) result + +val snapshot : + instrument_id:Id.Instrument.t -> + event_at:Ptime.t -> + available_at:Ptime.t -> + received_at:Ptime.t -> + ingest_sequence:int64 -> + book_sequence:int64 -> + bids:level list -> + asks:level list -> + (t, string) result + +val set : + instrument_id:Id.Instrument.t -> + event_at:Ptime.t -> + available_at:Ptime.t -> + received_at:Ptime.t -> + ingest_sequence:int64 -> + book_sequence:int64 -> + side:side -> + price:Scalar.Price.t -> + quantity:Scalar.Quantity.t -> + (t, string) result + +val delete : + instrument_id:Id.Instrument.t -> + event_at:Ptime.t -> + available_at:Ptime.t -> + received_at:Ptime.t -> + ingest_sequence:int64 -> + book_sequence:int64 -> + side:side -> + price:Scalar.Price.t -> + (t, string) result + +val trade : + instrument_id:Id.Instrument.t -> + event_at:Ptime.t -> + available_at:Ptime.t -> + received_at:Ptime.t -> + ingest_sequence:int64 -> + book_sequence:int64 -> + price:Scalar.Price.t -> + quantity:Scalar.Quantity.t -> + aggressor_side:Market_event.aggressor_side -> + (t, string) result + +val compare_replay_order : t -> t -> int +val side_to_string : side -> string +val side_of_string : string -> (side, string) result diff --git a/lib/process_tree_stubs.c b/lib/process_tree_stubs.c new file mode 100644 index 0000000..d1ae03a --- /dev/null +++ b/lib/process_tree_stubs.c @@ -0,0 +1,15 @@ +#include + +#if defined(__linux__) +#include +#endif + +CAMLprim value trading_engine_enable_child_subreaper(value unit) +{ + (void)unit; +#if defined(__linux__) + return Val_int(prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0)); +#else + return Val_int(0); +#endif +} diff --git a/lib/reducer_phases.ml b/lib/reducer_phases.ml new file mode 100644 index 0000000..b49b409 --- /dev/null +++ b/lib/reducer_phases.ml @@ -0,0 +1,120 @@ +module type CONTRACT = sig + type state + type reduction + type market_slice + type cursor + type audit + type context + type event + type intent + + module Validation : sig + val run : state -> market_slice -> (unit, string) result + end + + module Initialize : sig + val run : state -> market_slice -> (reduction, string) result + end + + module Actions : sig + val run : market_slice -> reduction -> (reduction, string) result + end + + module Borrow : sig + val run : market_slice -> reduction -> (reduction, string) result + end + + module Notifications : sig + type request + type outcome = Drained of reduction | Awaiting of request + + val run : reduction -> (outcome, string) result + val has_pending : reduction -> bool + val payload : request -> context * event + val resume : request -> intent list -> reduction + end + + module Matching : sig + type outcome = Continue of reduction * cursor | Complete of reduction + + val start : market_slice -> reduction -> (cursor, string) result + val run : market_slice -> cursor -> reduction -> (outcome, string) result + end + + module Targets : sig + val run : reduction -> (reduction, string) result + end + + module Margin : sig + val run : reduction -> (reduction, string) result + end + + module Valuation : sig + val run : reduction -> (state * audit list, string) result + end +end + +module Make (Contract : CONTRACT) = struct + let ( let* ) result function_ = + match result with Ok value -> function_ value | Error _ as error -> error + + type phase = + | Matching of Contract.market_slice * Contract.cursor + | Targets + | Finish + + type progress = + | Awaiting_strategy of Contract.Notifications.request * phase + | Slice_completed of Contract.state * Contract.audit list + + let rec transition phase reduction = + let* drained = Contract.Notifications.run reduction in + match drained with + | Contract.Notifications.Awaiting request -> + Ok (Awaiting_strategy (request, phase)) + | Contract.Notifications.Drained reduction -> ( + match phase with + | Matching (market_slice, cursor) -> ( + let* outcome = + Contract.Matching.run market_slice cursor reduction + in + match outcome with + | Contract.Matching.Continue (reduction, cursor) -> + transition (Matching (market_slice, cursor)) reduction + | Contract.Matching.Complete reduction -> + transition Targets reduction) + | Targets -> + let* reduction = Contract.Targets.run reduction in + transition Finish reduction + | Finish -> + let* reduction = Contract.Margin.run reduction in + if Contract.Notifications.has_pending reduction then + transition Finish reduction + else + let* state, audits = Contract.Valuation.run reduction in + Ok (Slice_completed (state, audits))) + + let process_slice state market_slice = + let* () = Contract.Validation.run state market_slice in + let* reduction = Contract.Initialize.run state market_slice in + let* reduction = Contract.Actions.run market_slice reduction in + let* reduction = Contract.Borrow.run market_slice reduction in + let* cursor = Contract.Matching.start market_slice reduction in + transition (Matching (market_slice, cursor)) reduction + + let strategy_request = function + | Awaiting_strategy (request, _) -> + Some (Contract.Notifications.payload request) + | Slice_completed _ -> None + + let resume progress intents = + match progress with + | Slice_completed _ -> + Error "completed slice cannot accept strategy intents" + | Awaiting_strategy (request, phase) -> + transition phase (Contract.Notifications.resume request intents) + + let slice_result = function + | Awaiting_strategy _ -> None + | Slice_completed (state, audits) -> Some (state, audits) +end diff --git a/lib/reducer_phases.mli b/lib/reducer_phases.mli new file mode 100644 index 0000000..da26345 --- /dev/null +++ b/lib/reducer_phases.mli @@ -0,0 +1,93 @@ +(** Pure reducer-phase sequencing behind one transition contract. + + The contract keeps domain state opaque. Every phase receives an immutable + reduction and returns either a replacement reduction or a completed slice; + only this module decides which phase runs next. *) + +module type CONTRACT = sig + type state + type reduction + type market_slice + type cursor + type audit + type context + type event + type intent + + module Validation : sig + val run : state -> market_slice -> (unit, string) result + (** Read-only ingress check. Success guarantees that later phases receive a + new, ordered, catalog-complete slice. *) + end + + module Initialize : sig + val run : state -> market_slice -> (reduction, string) result + (** Captures the slice snapshot and creates its reduction. The result owns + one received-slice audit and an empty feedback queue. *) + end + + module Actions : sig + val run : market_slice -> reduction -> (reduction, string) result + (** Applies the complete corporate-action batch in source order before any + borrow accrual or matching. *) + end + + module Borrow : sig + val run : market_slice -> reduction -> (reduction, string) result + (** Accrues deterministic short-borrow fees against the action-adjusted + account before matching. *) + end + + module Notifications : sig + type request + type outcome = Drained of reduction | Awaiting of request + + val run : reduction -> (outcome, string) result + (** Drains accepted intents until empty or until a strategy callback must + suspend the transition. No later phase runs with pending feedback. *) + + val has_pending : reduction -> bool + val payload : request -> context * event + val resume : request -> intent list -> reduction + end + + module Matching : sig + type outcome = Continue of reduction * cursor | Complete of reduction + + val start : market_slice -> reduction -> (cursor, string) result + (** Fixes the eligible-order cursor after actions and borrow accrual. *) + + val run : market_slice -> cursor -> reduction -> (outcome, string) result + (** Executes at most one cursor step. [Continue] retains the same slice; + [Complete] has cancelled market remainders and queued the close + callback. *) + end + + module Targets : sig + val run : reduction -> (reduction, string) result + (** Reconciles persistent targets exactly once after the close callback. *) + end + + module Margin : sig + val run : reduction -> (reduction, string) result + (** Assesses the post-target account. Generated order notifications are + drained before this phase is reassessed. *) + end + + module Valuation : sig + val run : reduction -> (state * audit list, string) result + (** Terminates the slice with exactly one valuation and returns audits in + publication order. *) + end +end + +module Make (Contract : CONTRACT) : sig + type progress + + val process_slice : + Contract.state -> Contract.market_slice -> (progress, string) result + + val strategy_request : progress -> (Contract.context * Contract.event) option + val resume : progress -> Contract.intent list -> (progress, string) result + val slice_result : progress -> (Contract.state * Contract.audit list) option +end diff --git a/lib/replay.ml b/lib/replay.ml index dc3b63a..cfdd9ec 100644 --- a/lib/replay.ml +++ b/lib/replay.ml @@ -28,6 +28,29 @@ type stream_state = { schedule_count : int64; } +let reducer ?sequence message = + Diagnostic.make ?sequence ~code:Diagnostic.Reducer_failed + ~phase:Diagnostic.Reducer message + +let replay ?sequence message = + Diagnostic.make ?sequence ~code:Diagnostic.Replay_failed + ~phase:Diagnostic.Replay message + +let reducer_result ?sequence result = + Result.map_error + (fun message -> + if + String.starts_with + ~prefix:"internal event count exceeds configured limit" message + then + Diagnostic.make ?sequence ~code:Diagnostic.Resource_limit + ~phase:Diagnostic.Reducer message + else reducer ?sequence message) + result + +let ( let* ) result function_ = + match result with Ok value -> function_ value | Error _ as error -> error + let append_events journal events = match journal with | None -> Ok () @@ -42,14 +65,36 @@ let append_events journal events = let add_audit_count count events = let added = Int64.of_int (List.length events) in if Int64.compare count (Int64.sub Int64.max_int added) > 0 then - Error "audit event count is exhausted" + Error (replay "audit event count is exhausted") else Ok (Int64.add count added) -let run ~scenario_sha256 ?journal_path scenario = +let engine_config ~contract_version ~risk ~venue_calendars ~execution_model + ~execution ~financing ~settlement ~max_internal_events = + Engine.config ~contract_version ~risk ~venue_calendars ~execution_model + ~execution ~financing ~settlement ~max_internal_events + +let run ~scenario_sha256 ?journal_path ?(durability = Artifact_writer.Buffered) + scenario = + let* strategy_state = + Scripted_strategy.create scenario.Scenario.schedule |> reducer_result + in + let* config = + engine_config ~contract_version:scenario.contract_version + ~risk:scenario.risk ~venue_calendars:scenario.venue_calendars + ~execution_model:scenario.execution_model ~execution:scenario.execution + ~financing:scenario.financing ~settlement:scenario.settlement + ~max_internal_events:scenario.max_internal_events + |> reducer_result + in + let* initial = + Runner.create ~run_id:scenario.run_id ~scenario_sha256 ~config + ~initial_portfolio:scenario.initial_portfolio ~strategy_state + |> reducer_result + in let journal_result = match journal_path with | None -> Ok None - | Some path -> Journal.create path |> Result.map Option.some + | Some path -> Journal.create ~durability path |> Result.map Option.some in match journal_result with | Error _ as error -> error @@ -66,75 +111,63 @@ let run ~scenario_sha256 ?journal_path scenario = | Ok () -> result | Error _ as error -> error) in - match Scripted_strategy.create scenario.Scenario.schedule with + let step result market_slice = + match result with + | Error _ as error -> error + | Ok (state, audits_rev) -> ( + match + Runner.process_slice state market_slice + |> reducer_result + ~sequence:market_slice.Market_slice.slice_sequence + with + | Error _ as error -> error + | Ok (state, events) -> ( + match append_events journal events with + | Error _ as error -> error + | Ok () -> Ok (state, List.rev_append events audits_rev))) + in + match List.fold_left step (Ok (initial, [])) scenario.slices with | Error _ as error -> fail error - | Ok strategy_state -> ( - match - Engine.config ~risk:scenario.risk - ~execution_model:scenario.execution_model - ~execution:scenario.execution - ~max_internal_events:scenario.max_internal_events - with + | Ok (state, audits_rev) -> ( + match Runner.complete state |> reducer_result with | Error _ as error -> fail error - | Ok config -> ( - match - Runner.create ~run_id:scenario.run_id ~scenario_sha256 ~config - ~initial_cash:scenario.initial_cash ~strategy_state - with + | Ok (state, valuation, completion_events) -> ( + match append_events journal completion_events with | Error _ as error -> fail error - | Ok initial -> ( - let step result market_slice = - match result with - | Error _ as error -> error - | Ok (state, audits_rev) -> ( - match Runner.process_slice state market_slice with - | Error _ as error -> error - | Ok (state, events) -> ( - match append_events journal events with - | Error _ as error -> error - | Ok () -> - Ok (state, List.rev_append events audits_rev))) + | Ok () -> + let audits_rev = + List.rev_append completion_events audits_rev in - match - List.fold_left step (Ok (initial, [])) scenario.slices - with - | Error _ as error -> fail error - | Ok (state, audits_rev) -> ( - match Runner.complete state with - | Error _ as error -> fail error - | Ok (state, valuation, completion_events) -> ( - match append_events journal completion_events with - | Error _ as error -> fail error - | Ok () -> - let audits_rev = - List.rev_append completion_events audits_rev - in - succeed - (Ok - { - account = Runner.account state; - orders = Oms.orders (Runner.oms state); - valuation; - audits = List.rev audits_rev; - }))))))) + succeed + (Ok + { + account = Runner.account state; + orders = Oms.orders (Runner.oms state); + valuation; + audits = List.rev audits_rev; + })))) let run_stream_pass ~scenario_sha256 ~journal channel = Scenario_stream.fold_channel channel ~init:(fun header -> - match Scripted_strategy.create [] with + match Scripted_strategy.create [] |> reducer_result with | Error _ as error -> error | Ok strategy_state -> ( match - Engine.config ~risk:header.Scenario.risk + engine_config ~contract_version:header.contract_version + ~risk:header.Scenario.risk ~venue_calendars:header.venue_calendars ~execution_model:header.execution_model - ~execution:header.execution + ~execution:header.execution ~financing:header.financing + ~settlement:header.settlement ~max_internal_events:header.max_internal_events + |> reducer_result with | Error _ as error -> error | Ok config -> ( match Runner.create ~run_id:header.run_id ~scenario_sha256 ~config - ~initial_cash:header.initial_cash ~strategy_state + ~initial_portfolio:header.initial_portfolio ~strategy_state + |> reducer_result with | Error _ as error -> error | Ok runner -> @@ -151,11 +184,15 @@ let run_stream_pass ~scenario_sha256 ~journal channel = match Scripted_strategy.create [ (item.Scenario.market_slice.slice_sequence, item.intents) ] + |> reducer_result ~sequence:item.market_slice.slice_sequence with | Error _ as error -> error | Ok strategy_state -> ( let runner = Runner.with_strategy_state state.runner strategy_state in - match Runner.process_slice runner item.market_slice with + match + Runner.process_slice runner item.market_slice + |> reducer_result ~sequence:item.market_slice.slice_sequence + with | Error _ as error -> error | Ok (runner, events) -> ( match append_events state.journal events with @@ -169,7 +206,7 @@ let run_stream_pass ~scenario_sha256 ~journal channel = |> Result.map (fun audit_count -> { state with runner; audit_count; schedule_count })))) ~finish:(fun state ~slice_count -> - match Runner.complete state.runner with + match Runner.complete state.runner |> reducer_result with | Error _ as error -> error | Ok (runner, valuation, events) -> ( match append_events state.journal events with @@ -189,7 +226,7 @@ let run_stream_pass ~scenario_sha256 ~journal channel = slice_count; }))) -let run_stream ?journal_path path = +let run_stream ?journal_path ?(durability = Artifact_writer.Buffered) path = let journal = ref None in let fail result = Option.iter Journal.close_preserving_partial !journal; @@ -205,12 +242,15 @@ let run_stream ?journal_path path = seek_in channel 0; let validated_sha256 = Sha256.digest_channel channel in if not (String.equal scenario_sha256 validated_sha256) then - Error "scenario stream changed during validation" + Error + (Diagnostic.make ~code:Diagnostic.Scenario_stream_changed + ~phase:Diagnostic.Input + "scenario stream changed during validation") else match journal_path with | None -> Ok validated | Some path -> ( - match Journal.create path with + match Journal.create ~durability path with | Error _ as error -> error | Ok created -> ( journal := Some created; @@ -225,10 +265,20 @@ let run_stream ?journal_path path = let replayed_sha256 = Sha256.digest_channel channel in if not (String.equal scenario_sha256 replayed_sha256) then - fail (Error "scenario stream changed during replay") + fail + (Error + (Diagnostic.make + ~code:Diagnostic.Scenario_stream_changed + ~phase:Diagnostic.Input + "scenario stream changed during replay")) else match Journal.commit created with | Error _ as error -> error | Ok () -> Ok replayed))))) - with Sys_error message -> - fail (Error ("could not read scenario stream: " ^ message)) + with Sys_error message as exception_ -> + fail + (Error + (Diagnostic.of_exception ~code:Diagnostic.Input_io + ~phase:Diagnostic.Input + ~message:("could not read scenario stream: " ^ message) + exception_)) diff --git a/lib/replay.mli b/lib/replay.mli index 3ee4523..d460cde 100644 --- a/lib/replay.mli +++ b/lib/replay.mli @@ -23,8 +23,12 @@ type streamed_result = private { val run : scenario_sha256:string -> ?journal_path:string -> + ?durability:Artifact_writer.durability -> Scenario.t -> - (result, string) Stdlib.result + (result, Diagnostic.t) Stdlib.result val run_stream : - ?journal_path:string -> string -> (streamed_result, string) Stdlib.result + ?journal_path:string -> + ?durability:Artifact_writer.durability -> + string -> + (streamed_result, Diagnostic.t) Stdlib.result diff --git a/lib/resource_limits.ml b/lib/resource_limits.ml new file mode 100644 index 0000000..a2e0bd3 --- /dev/null +++ b/lib/resource_limits.ml @@ -0,0 +1,26 @@ +let version = "1" +let scenario_record_bytes = 1_048_576 +let scenario_stream_bytes = 1_073_741_824 +let strategy_message_bytes = 1_048_576 +let internal_events = 100_000 +let catalog_instruments = 4_096 +let intents_per_batch = 4_096 +let artifact_record_bytes = 2_097_152 +let metric_name_bytes = 128 +let metric_string_value_bytes = 1_024 +let metric_unit_bytes = 64 +let metric_dimensions = 16 +let metric_dimension_key_bytes = 64 +let metric_dimension_value_bytes = 128 + +let to_yojson () = + `Assoc + [ + ("version", `String version); + ("scenario_record_bytes", `Int scenario_record_bytes); + ("strategy_message_bytes", `Int strategy_message_bytes); + ("internal_events", `Int internal_events); + ("catalog_instruments", `Int catalog_instruments); + ("intents_per_batch", `Int intents_per_batch); + ("artifact_record_bytes", `Int artifact_record_bytes); + ] diff --git a/lib/resource_limits.mli b/lib/resource_limits.mli new file mode 100644 index 0000000..15657a2 --- /dev/null +++ b/lib/resource_limits.mli @@ -0,0 +1,20 @@ +(** Versioned inclusive limits for boundary and reducer resources. + + Scenario record bytes exclude the line feed. Artifact record bytes include + it. *) + +val version : string +val scenario_record_bytes : int +val scenario_stream_bytes : int +val strategy_message_bytes : int +val internal_events : int +val catalog_instruments : int +val intents_per_batch : int +val artifact_record_bytes : int +val metric_name_bytes : int +val metric_string_value_bytes : int +val metric_unit_bytes : int +val metric_dimensions : int +val metric_dimension_key_bytes : int +val metric_dimension_value_bytes : int +val to_yojson : unit -> Yojson.Safe.t diff --git a/lib/risk.ml b/lib/risk.ml index 2fe7d42..8f5d058 100644 --- a/lib/risk.ml +++ b/lib/risk.ml @@ -1,6 +1,8 @@ type t = { base_currency : string; instruments : Instrument.t Id.Instrument.Map.t; + instrument_policies : instrument_policy Id.Instrument.Map.t; + groups : group list; max_order_quantity : Scalar.Quantity.t; max_long_position : Scalar.Quantity.t; max_short_position : Scalar.Quantity.t; @@ -8,7 +10,43 @@ type t = { max_leverage : Scalar.Ratio.t; initial_margin_bps : int; maintenance_margin_bps : int; - short_borrow_bps : int; +} + +and instrument_policy = { + instrument_id : Id.Instrument.t; + max_order_quantity : Scalar.Quantity.t; + max_long_position : Scalar.Quantity.t; + max_short_position : Scalar.Quantity.t; + max_notional_exposure : Scalar.Money.t option; + initial_margin_bps : int; + maintenance_margin_bps : int; + shorting_allowed : bool; +} + +and group_kind = Issuer | Sector | Currency | Country | Asset_class | Custom + +and group_limits = { + max_gross_exposure : Scalar.Money.t option; + max_long_exposure : Scalar.Money.t option; + max_short_exposure : Scalar.Money.t option; + max_absolute_net_exposure : Scalar.Money.t option; + max_concentration : Scalar.Ratio.t option; +} + +and group = { + group_id : Id.Risk_group.t; + group_kind : group_kind; + instrument_ids : Id.Instrument.t list; + limits : group_limits; +} + +type group_exposure = { + group_id : Id.Risk_group.t; + gross_exposure : Scalar.Money.t; + net_exposure : Scalar.Money.t; + long_exposure : Scalar.Money.t; + short_exposure : Scalar.Money.t; + concentration : Scalar.Weight.t option; } type margin_snapshot = { @@ -17,8 +55,32 @@ type margin_snapshot = { initial_excess : Scalar.Money.t; maintenance_excess : Scalar.Money.t; margin_call : bool; + group_exposures : group_exposure list; } +type fill_limit = + | Maximum_order_quantity of Scalar.Quantity.t + | Maximum_long_position of Scalar.Quantity.t + | Maximum_short_position of Scalar.Quantity.t + | Maximum_gross_exposure of Scalar.Money.t + | Maximum_leverage of Scalar.Ratio.t + | Initial_margin of int + | Instrument_maximum_long_position of Id.Instrument.t * Scalar.Quantity.t + | Instrument_maximum_short_position of Id.Instrument.t * Scalar.Quantity.t + | Instrument_maximum_notional of Id.Instrument.t * Scalar.Money.t + | Instrument_shorting_disabled of Id.Instrument.t + | Instrument_borrow_availability of Id.Instrument.t * Scalar.Quantity.t + | Settlement_cash_buying_power of string * Scalar.Money.t + | Settlement_position_availability of Id.Instrument.t * Scalar.Quantity.t + | Instrument_initial_margin of Id.Instrument.t * int + | Group_maximum_gross of Id.Risk_group.t * Scalar.Money.t + | Group_maximum_long of Id.Risk_group.t * Scalar.Money.t + | Group_maximum_short of Id.Risk_group.t * Scalar.Money.t + | Group_maximum_absolute_net of Id.Risk_group.t * Scalar.Money.t + | Group_maximum_concentration of Id.Risk_group.t * Scalar.Ratio.t + +type fill_check_error = Limit of fill_limit | Invalid of string + let ( let* ) result function_ = match result with Ok value -> function_ value | Error _ as error -> error @@ -30,75 +92,175 @@ let valid_label value = code >= 0x21 && code <> 0x7f) value -let create ~base_currency ~instruments ~max_order_quantity ~max_long_position - ~max_short_position ~max_gross_exposure ~max_leverage ~initial_margin_bps - ~maintenance_margin_bps ~short_borrow_bps = - if not (valid_label base_currency) then - Error "base currency must not be empty or contain whitespace" - else if not (Scalar.Quantity.is_positive max_order_quantity) then - Error "maximum order quantity must be positive" - else if not (Scalar.Quantity.is_positive max_long_position) then - Error "maximum long position must be positive" - else if not (Scalar.Quantity.is_positive max_short_position) then - Error "maximum short position must be positive" - else if Scalar.Money.compare max_gross_exposure Scalar.Money.zero <= 0 then - Error "maximum gross exposure must be positive" - else if initial_margin_bps <= 0 || initial_margin_bps > 10_000 then +let valid_margin ~initial_margin_bps ~maintenance_margin_bps = + if initial_margin_bps <= 0 || initial_margin_bps > 10_000 then Error "initial margin basis points must be between 1 and 10000" else if maintenance_margin_bps <= 0 || maintenance_margin_bps > 10_000 then Error "maintenance margin basis points must be between 1 and 10000" else if initial_margin_bps < maintenance_margin_bps then Error "initial margin must not be below maintenance margin" - else if short_borrow_bps < 0 || short_borrow_bps > 10_000 then - Error "short borrow basis points must be between 0 and 10000" - else if instruments = [] then Error "risk must define at least one instrument" - else if - List.exists - (fun instrument -> - Scalar.Quantity.compare max_order_quantity - instrument.Instrument.lot_size - < 0) - instruments - then Error "maximum order quantity must cover every instrument lot size" - else if - List.exists - (fun instrument -> - Scalar.Quantity.compare max_long_position instrument.Instrument.lot_size - < 0) - instruments - then Error "maximum long position must cover every instrument lot size" + else Ok () + +let create_instrument_policy ~instrument ~max_order_quantity ~max_long_position + ~max_short_position ~max_notional_exposure ~initial_margin_bps + ~maintenance_margin_bps ~shorting_allowed = + let lot = instrument.Instrument.lot_size in + if not (Scalar.Quantity.is_positive max_order_quantity) then + Error "maximum order quantity must be positive" + else if not (Scalar.Quantity.is_positive max_long_position) then + Error "maximum long position must be positive" + else if not (Scalar.Quantity.is_positive max_short_position) then + Error "maximum short position must be positive" + else if Scalar.Quantity.compare max_order_quantity lot < 0 then + Error "maximum order quantity must cover the instrument lot size" + else if Scalar.Quantity.compare max_long_position lot < 0 then + Error "maximum long position must cover the instrument lot size" + else if Scalar.Quantity.compare max_short_position lot < 0 then + Error "maximum short position must cover the instrument lot size" else if - List.exists - (fun instrument -> - Scalar.Quantity.compare max_short_position - instrument.Instrument.lot_size - < 0) - instruments - then Error "maximum short position must cover every instrument lot size" + Option.exists + (fun value -> Scalar.Money.compare value Scalar.Money.zero <= 0) + max_notional_exposure + then Error "maximum instrument notional exposure must be positive" else - let add result instrument = - let* map = result in - if Id.Instrument.Map.mem instrument.Instrument.id map then - Error "instrument IDs must be unique" - else Ok (Id.Instrument.Map.add instrument.id instrument map) - in - let* instruments = - List.fold_left add (Ok Id.Instrument.Map.empty) instruments - in + let* () = valid_margin ~initial_margin_bps ~maintenance_margin_bps in Ok { - base_currency; - instruments; + instrument_id = instrument.id; max_order_quantity; max_long_position; max_short_position; - max_gross_exposure; - max_leverage; + max_notional_exposure; initial_margin_bps; maintenance_margin_bps; - short_borrow_bps; + shorting_allowed; + } + +let create_group_limits ~max_gross_exposure ~max_long_exposure + ~max_short_exposure ~max_absolute_net_exposure ~max_concentration = + let positive_money = function + | None -> true + | Some value -> Scalar.Money.compare value Scalar.Money.zero > 0 + in + if + not + (List.for_all positive_money + [ + max_gross_exposure; + max_long_exposure; + max_short_exposure; + max_absolute_net_exposure; + ]) + then Error "group money limits must be positive" + else if + Option.exists + (fun value -> Scalar.Ratio.compare value Scalar.Ratio.one > 0) + max_concentration + then Error "group concentration must be greater than zero and at most one" + else if + List.for_all Option.is_none + [ + max_gross_exposure; + max_long_exposure; + max_short_exposure; + max_absolute_net_exposure; + ] + && Option.is_none max_concentration + then Error "group must configure at least one limit" + else + Ok + { + max_gross_exposure; + max_long_exposure; + max_short_exposure; + max_absolute_net_exposure; + max_concentration; + } + +let create_group ~group_id ~group_kind ~instrument_ids ~limits = + if instrument_ids = [] then Error "risk group must contain an instrument" + else if + List.length instrument_ids + <> List.length (List.sort_uniq Id.Instrument.compare instrument_ids) + then Error "risk group instrument IDs must be unique" + else + Ok + { + group_id; + group_kind; + instrument_ids = List.sort Id.Instrument.compare instrument_ids; + limits; } +let create ~base_currency ~instruments + ~(instrument_policies : instrument_policy list) ~(groups : group list) + ~max_gross_exposure ~max_leverage = + if not (valid_label base_currency) then + Error "base currency must not be empty or contain whitespace" + else if instruments = [] then Error "risk must define at least one instrument" + else if Scalar.Money.compare max_gross_exposure Scalar.Money.zero <= 0 then + Error "maximum gross exposure must be positive" + else + let add_instrument result instrument = + let* map = result in + if Id.Instrument.Map.mem instrument.Instrument.id map then + Error "instrument IDs must be unique" + else Ok (Id.Instrument.Map.add instrument.id instrument map) + in + let* instrument_map = + List.fold_left add_instrument (Ok Id.Instrument.Map.empty) instruments + in + let add_policy result policy = + let* map = result in + if not (Id.Instrument.Map.mem policy.instrument_id instrument_map) then + Error "instrument policy refers to an unknown instrument" + else if Id.Instrument.Map.mem policy.instrument_id map then + Error "instrument policy IDs must be unique" + else Ok (Id.Instrument.Map.add policy.instrument_id policy map) + in + let* policy_map = + List.fold_left add_policy (Ok Id.Instrument.Map.empty) instrument_policies + in + if + Id.Instrument.Map.cardinal policy_map + <> Id.Instrument.Map.cardinal instrument_map + then Error "risk must define exactly one policy for every instrument" + else + let group_ids = List.map (fun (group : group) -> group.group_id) groups in + if + List.length group_ids + <> List.length (List.sort_uniq Id.Risk_group.compare group_ids) + then Error "risk group IDs must be unique" + else if + List.exists + (fun (group : group) -> + List.exists + (fun instrument_id -> + not (Id.Instrument.Map.mem instrument_id instrument_map)) + group.instrument_ids) + groups + then Error "risk group refers to an unknown instrument" + else + let representative = List.hd instrument_policies in + Ok + { + base_currency; + instruments = instrument_map; + instrument_policies = policy_map; + groups = + List.sort + (fun (left : group) right -> + Id.Risk_group.compare left.group_id right.group_id) + groups; + max_order_quantity = representative.max_order_quantity; + max_long_position = representative.max_long_position; + max_short_position = representative.max_short_position; + max_gross_exposure; + max_leverage; + initial_margin_bps = representative.initial_margin_bps; + maintenance_margin_bps = representative.maintenance_margin_bps; + } + let base_currency state = state.base_currency let instruments state = @@ -107,6 +269,13 @@ let instruments state = let instrument state instrument_id = Id.Instrument.Map.find_opt instrument_id state.instruments +let instrument_policies state = + Id.Instrument.Map.bindings state.instrument_policies |> List.map snd + +let instrument_policy state instrument_id = + Id.Instrument.Map.find_opt instrument_id state.instrument_policies + +let groups state = state.groups let max_order_quantity state = state.max_order_quantity let max_long_position state = state.max_long_position let max_short_position state = state.max_short_position @@ -114,23 +283,94 @@ let max_gross_exposure state = state.max_gross_exposure let max_leverage state = state.max_leverage let initial_margin_bps state = state.initial_margin_bps let maintenance_margin_bps state = state.maintenance_margin_bps -let short_borrow_bps state = state.short_borrow_bps -let margin_snapshot state valuation = - let* initial_requirement = - Scalar.Money.bps_ceil valuation.Account.gross_exposure - ~bps:state.initial_margin_bps +let max_order_quantity_for state instrument_id = + Option.map + (fun (policy : instrument_policy) -> policy.max_order_quantity) + (instrument_policy state instrument_id) + +let group_exposure_from_positions group ~equity positions = + let member instrument_id = + List.exists (Id.Instrument.equal instrument_id) group.instrument_ids + in + let* net, long, short = + List.fold_left + (fun result (position : Account.position_attribution) -> + let* net, long, short = result in + if not (member position.instrument_id) then Ok (net, long, short) + else + let* net = Scalar.Money.add net position.base_market_value in + if + Scalar.Money.compare position.base_market_value Scalar.Money.zero + >= 0 + then + let* long = Scalar.Money.add long position.base_market_value in + Ok (net, long, short) + else + let* magnitude = Scalar.Money.negate position.base_market_value in + let* short = Scalar.Money.add short magnitude in + Ok (net, long, short)) + (Ok (Scalar.Money.zero, Scalar.Money.zero, Scalar.Money.zero)) + positions in - let* maintenance_requirement = - Scalar.Money.bps_ceil valuation.gross_exposure - ~bps:state.maintenance_margin_bps + let* gross = Scalar.Money.add long short in + let* concentration = + if Scalar.Money.compare equity Scalar.Money.zero <= 0 then Ok None + else Scalar.Money.weight_toward_zero gross ~equity |> Result.map Option.some in + Ok + { + group_id = group.group_id; + gross_exposure = gross; + net_exposure = net; + long_exposure = long; + short_exposure = short; + concentration; + } + +let group_exposures state (valuation : Account.valuation) = + List.fold_left + (fun result group -> + let* exposures = result in + let* exposure = + group_exposure_from_positions group ~equity:valuation.Account.equity + valuation.positions + in + Ok (exposure :: exposures)) + (Ok []) state.groups + |> Result.map List.rev + +let margin_snapshot state (valuation : Account.valuation) = + let requirements = + List.fold_left + (fun result (position : Account.position_attribution) -> + let* initial, maintenance = result in + let* notional = Scalar.Money.absolute position.base_market_value in + let* policy = + match instrument_policy state position.instrument_id with + | Some policy -> Ok policy + | None -> Error "valuation position has no instrument risk policy" + in + let* item_initial = + Scalar.Money.bps_ceil notional ~bps:policy.initial_margin_bps + in + let* item_maintenance = + Scalar.Money.bps_ceil notional ~bps:policy.maintenance_margin_bps + in + let* initial = Scalar.Money.add initial item_initial in + let* maintenance = Scalar.Money.add maintenance item_maintenance in + Ok (initial, maintenance)) + (Ok (Scalar.Money.zero, Scalar.Money.zero)) + valuation.positions + in + let* initial_requirement, maintenance_requirement = requirements in let* initial_excess = Scalar.Money.subtract valuation.equity initial_requirement in let* maintenance_excess = Scalar.Money.subtract valuation.equity maintenance_requirement in + let* group_exposures = group_exposures state valuation in Ok { initial_requirement; @@ -139,37 +379,242 @@ let margin_snapshot state valuation = maintenance_excess; margin_call = Scalar.Money.compare maintenance_excess Scalar.Money.zero < 0; + group_exposures; } -let check_initial_values state ~equity ~gross_exposure = +let check_initial state valuation = + let* () = + if + Scalar.Money.compare valuation.Account.gross_exposure + state.max_gross_exposure + > 0 + then Error "portfolio would exceed maximum gross exposure" + else + let* leveraged_equity = + Scalar.Money.multiply_ratio valuation.equity state.max_leverage + in + if Scalar.Money.compare valuation.gross_exposure leveraged_equity > 0 then + Error "portfolio would exceed maximum leverage" + else Ok () + in + let* () = + List.fold_left + (fun result (position : Account.position_attribution) -> + let* () = result in + let* policy = + match instrument_policy state position.instrument_id with + | Some policy -> Ok policy + | None -> Error "initial position has no instrument risk policy" + in + let* minimum_short = Scalar.Quantity.negate policy.max_short_position in + let* () = + if + Scalar.Quantity.compare position.quantity policy.max_long_position + > 0 + then Error "initial position exceeds its maximum long position" + else if Scalar.Quantity.compare position.quantity minimum_short < 0 + then Error "initial position exceeds its maximum short position" + else if + (not policy.shorting_allowed) + && Scalar.Quantity.is_negative position.quantity + then Error "initial position violates its shorting policy" + else Ok () + in + let* notional = Scalar.Money.absolute position.base_market_value in + match policy.max_notional_exposure with + | Some limit when Scalar.Money.compare notional limit > 0 -> + Error + "initial position exceeds the instrument maximum notional \ + exposure" + | _ -> Ok ()) + (Ok ()) valuation.positions + in + let* margin = margin_snapshot state valuation in + let* () = + if Scalar.Money.compare margin.initial_excess Scalar.Money.zero < 0 then + Error "portfolio would violate instrument initial margin requirements" + else Ok () + in + List.fold_left + (fun result (group : group) -> + let* () = result in + let* exposure = + match + List.find_opt + (fun item -> Id.Risk_group.equal item.group_id group.group_id) + margin.group_exposures + with + | Some exposure -> Ok exposure + | None -> Error "initial valuation omitted a configured risk group" + in + let* absolute_net = Scalar.Money.absolute exposure.net_exposure in + let exceeds option observed = + Option.exists + (fun limit -> Scalar.Money.compare observed limit > 0) + option + in + let group_name = Id.Risk_group.to_string group.group_id in + if exceeds group.limits.max_gross_exposure exposure.gross_exposure then + Error + (Printf.sprintf + "initial portfolio exceeds group %s maximum gross exposure" + group_name) + else if exceeds group.limits.max_long_exposure exposure.long_exposure then + Error + (Printf.sprintf + "initial portfolio exceeds group %s maximum long exposure" + group_name) + else if exceeds group.limits.max_short_exposure exposure.short_exposure + then + Error + (Printf.sprintf + "initial portfolio exceeds group %s maximum short exposure" + group_name) + else if exceeds group.limits.max_absolute_net_exposure absolute_net then + Error + (Printf.sprintf + "initial portfolio exceeds group %s maximum absolute net exposure" + group_name) + else + match group.limits.max_concentration with + | None -> Ok () + | Some limit -> + let* threshold = + Scalar.Money.multiply_ratio valuation.equity limit + in + if Scalar.Money.compare exposure.gross_exposure threshold > 0 then + Error + (Printf.sprintf + "initial portfolio exceeds group %s maximum concentration" + group_name) + else Ok ()) + (Ok ()) state.groups + +let invalid result = Result.map_error (fun message -> Invalid message) result + +let check_fill_initial state ~equity ~gross_exposure = if Scalar.Money.compare gross_exposure state.max_gross_exposure > 0 then - Error "portfolio would exceed maximum gross exposure" + Error (Limit (Maximum_gross_exposure state.max_gross_exposure)) else let* leveraged_equity = - Scalar.Money.multiply_ratio equity state.max_leverage + Scalar.Money.multiply_ratio equity state.max_leverage |> invalid in if Scalar.Money.compare gross_exposure leveraged_equity > 0 then - Error "portfolio would exceed maximum leverage" + Error (Limit (Maximum_leverage state.max_leverage)) else let* initial_requirement = Scalar.Money.bps_ceil gross_exposure ~bps:state.initial_margin_bps + |> invalid + in + let* initial_excess = + Scalar.Money.subtract equity initial_requirement |> invalid in - let* initial_excess = Scalar.Money.subtract equity initial_requirement in if Scalar.Money.compare initial_excess Scalar.Money.zero < 0 then - Error "portfolio would violate initial margin" + Error (Limit (Initial_margin state.initial_margin_bps)) else Ok () -let check_initial state valuation = - check_initial_values state ~equity:valuation.Account.equity - ~gross_exposure:valuation.gross_exposure +let check_post_fill state ~before_position ~after_position ~before ~after = + let* before_absolute = Scalar.Quantity.absolute before_position |> invalid in + let* after_absolute = Scalar.Quantity.absolute after_position |> invalid in + if Scalar.Quantity.compare after_absolute before_absolute <= 0 then Ok () + else if Scalar.Quantity.compare after_position state.max_long_position > 0 + then Error (Limit (Maximum_long_position state.max_long_position)) + else + let* minimum_short = + Scalar.Quantity.negate state.max_short_position |> invalid + in + if Scalar.Quantity.compare after_position minimum_short < 0 then + Error (Limit (Maximum_short_position state.max_short_position)) + else if + Scalar.Money.compare after.Account.gross_exposure + before.Account.gross_exposure + <= 0 + then Ok () + else + check_fill_initial state ~equity:after.equity + ~gross_exposure:after.gross_exposure -let check_post_fill state ~before ~after = - if - Scalar.Money.compare after.Account.gross_exposure - before.Account.gross_exposure - <= 0 - then Ok () - else check_initial state after +type projected_value = { + instrument_id : Id.Instrument.t; + quantity : Scalar.Quantity.t; + signed_value : Scalar.Money.t; + absolute_value : Scalar.Money.t; +} + +let sum_values values = + List.fold_left + (fun result value -> + let* gross, long, short, net = result in + let* gross = Scalar.Money.add gross value.absolute_value in + let* net = Scalar.Money.add net value.signed_value in + if Scalar.Money.compare value.signed_value Scalar.Money.zero >= 0 then + let* long = Scalar.Money.add long value.signed_value in + Ok (gross, long, short, net) + else + let* magnitude = Scalar.Money.negate value.signed_value in + let* short = Scalar.Money.add short magnitude in + Ok (gross, long, short, net)) + (Ok + ( Scalar.Money.zero, + Scalar.Money.zero, + Scalar.Money.zero, + Scalar.Money.zero )) + values + +let group_values (group : group) values = + List.filter + (fun value -> + List.exists (Id.Instrument.equal value.instrument_id) group.instrument_ids) + values + |> sum_values + +let check_group_fill_limit state ~equity values = + let rec check = function + | [] -> Ok () + | (group : group) :: rest -> + let* gross, long, short, net = group_values group values |> invalid in + let* absolute_net = Scalar.Money.absolute net |> invalid in + let fail option observed make = + match option with + | Some limit when Scalar.Money.compare observed limit > 0 -> + Error (Limit (make group.group_id limit)) + | _ -> Ok () + in + let* () = + fail group.limits.max_gross_exposure gross (fun id limit -> + Group_maximum_gross (id, limit)) + in + let* () = + fail group.limits.max_long_exposure long (fun id limit -> + Group_maximum_long (id, limit)) + in + let* () = + fail group.limits.max_short_exposure short (fun id limit -> + Group_maximum_short (id, limit)) + in + let* () = + fail group.limits.max_absolute_net_exposure absolute_net + (fun id limit -> Group_maximum_absolute_net (id, limit)) + in + let* () = + match group.limits.max_concentration with + | None -> Ok () + | Some limit -> + let* threshold = + Scalar.Money.multiply_ratio equity limit |> invalid + in + if Scalar.Money.compare gross threshold > 0 then + Error + (Limit (Group_maximum_concentration (group.group_id, limit))) + else Ok () + in + check rest + in + check state.groups + +let check_post_fill_for _state ~instrument_id:_ ~before_position:_ + ~after_position:_ ~before:_ ~after:_ = + Ok () let check_position state quantity = if Scalar.Quantity.compare quantity state.max_long_position > 0 then @@ -180,6 +625,21 @@ let check_position state quantity = Error "position would exceed the maximum short position" else Ok () +let check_position_for state instrument_id quantity = + match instrument_policy state instrument_id with + | None -> Error "position refers to an unknown instrument risk policy" + | Some policy -> + if Scalar.Quantity.compare quantity policy.max_long_position > 0 then + Error "position would exceed the instrument maximum long position" + else + let* minimum_short = Scalar.Quantity.negate policy.max_short_position in + if Scalar.Quantity.compare quantity minimum_short < 0 then + Error "position would exceed the instrument maximum short position" + else if + (not policy.shorting_allowed) && Scalar.Quantity.is_negative quantity + then Error "instrument policy does not allow short positions" + else Ok () + let check_alignment instrument request = if not @@ -189,63 +649,142 @@ let check_alignment instrument request = else match request.kind with | Order.Market -> Ok () - | Order.Limit price -> + | Order.Limit price | Order.Stop price -> if Scalar.Price.is_multiple price ~tick:instrument.tick_size then Ok () - else Error "limit price is not aligned to the instrument tick size" + else Error "order price is not aligned to the instrument tick size" + | Order.Stop_limit { trigger_price; limit_price } -> + if + Scalar.Price.is_multiple trigger_price ~tick:instrument.tick_size + && Scalar.Price.is_multiple limit_price ~tick:instrument.tick_size + then Ok () + else Error "order price is not aligned to the instrument tick size" + +type reservations = { buys : Scalar.Quantity.t; sells : Scalar.Quantity.t } + +let empty_reservations = + { buys = Scalar.Quantity.zero; sells = Scalar.Quantity.zero } + +let reservations_for_instrument ~oms instrument_id = + Oms.active_for_instrument oms instrument_id + |> List.fold_left + (fun result order -> + let* reservations = result in + let remaining = Order.remaining_quantity order in + match order.Order.request.side with + | Order.Buy -> + let* buys = Scalar.Quantity.add reservations.buys remaining in + Ok { reservations with buys } + | Order.Sell -> + let* sells = Scalar.Quantity.add reservations.sells remaining in + Ok { reservations with sells }) + (Ok empty_reservations) -let signed_order_quantity request = +let add_request reservations request = match request.Order.side with - | Order.Buy -> Ok request.quantity - | Order.Sell -> Scalar.Quantity.negate request.quantity + | Order.Buy -> + let* buys = Scalar.Quantity.add reservations.buys request.quantity in + Ok { reservations with buys } + | Order.Sell -> + let* sells = Scalar.Quantity.add reservations.sells request.quantity in + Ok { reservations with sells } -let working_position ~account ~oms instrument_id = +let directional_positions ~account instrument_id reservations = let current = Account.position_quantity account instrument_id in - let active = Oms.active_for_instrument oms instrument_id in - List.fold_left - (fun result order -> - let* quantity = result in - let remaining = Order.remaining_quantity order in - let* delta = - match order.Order.request.side with - | Order.Buy -> Ok remaining - | Order.Sell -> Scalar.Quantity.negate remaining - in - Scalar.Quantity.add quantity delta) - (Ok current) active + let* buy_position = Scalar.Quantity.add current reservations.buys in + let* sell_position = Scalar.Quantity.subtract current reservations.sells in + Ok (buy_position, sell_position) + +let position_for_side side (buy_position, sell_position) = + match side with Order.Buy -> buy_position | Order.Sell -> sell_position + +let worst_directional_position positions = + let buy_position, sell_position = positions in + let* buy_absolute = Scalar.Quantity.absolute buy_position in + let* sell_absolute = Scalar.Quantity.absolute sell_position in + if Scalar.Quantity.compare buy_absolute sell_absolute >= 0 then + Ok buy_position + else Ok sell_position + +let check_self_cross ~oms request = + let active = Oms.active_for_instrument oms request.Order.instrument_id in + if + List.exists + (fun order -> order.Order.request.side <> request.Order.side) + active + then Error "order would self-cross an active opposite-side order" + else Ok () let projected_position ~account ~oms request = - let* pending = working_position ~account ~oms request.Order.instrument_id in - let* projected = - let* delta = signed_order_quantity request in - Scalar.Quantity.add pending delta + let* reservations = + reservations_for_instrument ~oms request.Order.instrument_id + in + let* pending_positions = + directional_positions ~account request.instrument_id reservations in + let pending = position_for_side request.side pending_positions in + let* projected_reservations = add_request reservations request in + let* projected_positions = + directional_positions ~account request.instrument_id projected_reservations + in + let projected = position_for_side request.side projected_positions in if Scalar.Quantity.is_positive pending && Scalar.Quantity.is_negative projected || Scalar.Quantity.is_negative pending && Scalar.Quantity.is_positive projected then Error "one order must not cross a position through zero" - else Ok projected + else Ok (pending, projected) -let projected_valuation_quantities state ~account ~oms request projected = +let projected_valuation_quantities state ~account ~oms request = Id.Instrument.Map.bindings state.instruments |> List.fold_left (fun result (instrument_id, _) -> let* values = result in - if Id.Instrument.equal instrument_id request.Order.instrument_id then - Ok ((instrument_id, projected) :: values) - else - let* quantity = working_position ~account ~oms instrument_id in - Ok ((instrument_id, quantity) :: values)) + let* reservations = reservations_for_instrument ~oms instrument_id in + let* reservations = + if Id.Instrument.equal instrument_id request.Order.instrument_id then + add_request reservations request + else Ok reservations + in + let* positions = + directional_positions ~account instrument_id reservations + in + let* quantity = worst_directional_position positions in + Ok ((instrument_id, quantity) :: values)) (Ok []) |> Result.map List.rev -let projected_gross_exposure state ~account ~oms ~marks ~fx_rates request = - let* projected = projected_position ~account ~oms request in - let* () = check_position state projected in - let* quantities = - projected_valuation_quantities state ~account ~oms request projected - in +let fill_projected_quantities state ~account ~oms ~(order : Order.t) + ~filled_quantity = + Id.Instrument.Map.bindings state.instruments + |> List.fold_left + (fun result (instrument_id, _) -> + let* values = result in + let* reservations = reservations_for_instrument ~oms instrument_id in + let* reservations = + if Id.Instrument.equal instrument_id order.request.instrument_id then + match order.request.side with + | Order.Buy -> + let* buys = + Scalar.Quantity.subtract reservations.buys filled_quantity + in + Ok { reservations with buys } + | Order.Sell -> + let* sells = + Scalar.Quantity.subtract reservations.sells filled_quantity + in + Ok { reservations with sells } + else Ok reservations + in + let* positions = + directional_positions ~account instrument_id reservations + in + let* quantity = worst_directional_position positions in + Ok ((instrument_id, quantity) :: values)) + (Ok []) + |> Result.map List.rev + +let projected_values state ~marks ~fx_rates quantities = let mark_map = List.fold_left (fun map (instrument_id, mark) -> @@ -258,57 +797,269 @@ let projected_gross_exposure state ~account ~oms ~marks ~fx_rates request = (fun map (currency, rate) -> Currency_map.add currency rate map) Currency_map.empty fx_rates in - let* gross_exposure = + List.fold_left + (fun result (instrument_id, quantity) -> + let* values = result in + let* instrument = + match instrument state instrument_id with + | Some value -> Ok value + | None -> Error "projected position has no configured instrument" + in + let* mark = + match Id.Instrument.Map.find_opt instrument_id mark_map with + | Some value -> Ok value + | None -> Error "projected position has no current market price" + in + let* rate = + match Currency_map.find_opt instrument.quote_currency fx_map with + | Some value -> Ok value + | None -> Error "projected position has no current FX rate" + in + let* signed_value = Scalar.Money.notional mark quantity in + let* signed_value = Scalar.Money.convert signed_value ~rate in + let* absolute_value = Scalar.Money.absolute signed_value in + Ok ({ instrument_id; quantity; signed_value; absolute_value } :: values)) + (Ok []) quantities + |> Result.map List.rev + +let first_some checks = + let rec loop = function + | [] -> Ok () + | check :: rest -> ( + match check () with Ok () -> loop rest | Error _ as e -> e) + in + loop checks + +let check_projected_values state ~equity values = + let* gross, _, _, _ = sum_values values in + let* () = + if Scalar.Money.compare gross state.max_gross_exposure > 0 then + Error "portfolio would exceed maximum gross exposure" + else + let* leveraged_equity = + Scalar.Money.multiply_ratio equity state.max_leverage + in + if Scalar.Money.compare gross leveraged_equity > 0 then + Error "portfolio would exceed maximum leverage" + else + let* initial_requirement = + List.fold_left + (fun result value -> + let* total = result in + let* policy = + match instrument_policy state value.instrument_id with + | Some policy -> Ok policy + | None -> + Error "projected position has no instrument risk policy" + in + let* requirement = + Scalar.Money.bps_ceil value.absolute_value + ~bps:policy.initial_margin_bps + in + Scalar.Money.add total requirement) + (Ok Scalar.Money.zero) values + in + let* excess = Scalar.Money.subtract equity initial_requirement in + if Scalar.Money.compare excess Scalar.Money.zero < 0 then + Error "portfolio would violate instrument initial margin requirements" + else Ok () + in + let* () = List.fold_left - (fun result (instrument_id, quantity) -> - let* gross = result in - let* instrument = - match instrument state instrument_id with - | Some value -> Ok value - | None -> Error "projected position has no configured instrument" + (fun result value -> + let* () = result in + let* policy = + match instrument_policy state value.instrument_id with + | Some policy -> Ok policy + | None -> Error "projected position has no instrument risk policy" in - let* mark = - match Id.Instrument.Map.find_opt instrument_id mark_map with - | Some value -> Ok value - | None -> Error "projected position has no current market price" + let* () = check_position_for state value.instrument_id value.quantity in + match policy.max_notional_exposure with + | Some limit when Scalar.Money.compare value.absolute_value limit > 0 -> + Error + "position would exceed the instrument maximum notional exposure" + | _ -> Ok ()) + (Ok ()) values + in + List.fold_left + (fun result (group : group) -> + let* () = result in + let* gross, long, short, net = group_values group values in + let* absolute_net = Scalar.Money.absolute net in + let concentration_exceeded limit = + let* threshold = Scalar.Money.multiply_ratio equity limit in + Ok (Scalar.Money.compare gross threshold > 0) + in + first_some + [ + (fun () -> + match group.limits.max_gross_exposure with + | Some limit when Scalar.Money.compare gross limit > 0 -> + Error + (Printf.sprintf + "position would exceed group %s maximum gross exposure" + (Id.Risk_group.to_string group.group_id)) + | _ -> Ok ()); + (fun () -> + match group.limits.max_long_exposure with + | Some limit when Scalar.Money.compare long limit > 0 -> + Error + (Printf.sprintf + "position would exceed group %s maximum long exposure" + (Id.Risk_group.to_string group.group_id)) + | _ -> Ok ()); + (fun () -> + match group.limits.max_short_exposure with + | Some limit when Scalar.Money.compare short limit > 0 -> + Error + (Printf.sprintf + "position would exceed group %s maximum short exposure" + (Id.Risk_group.to_string group.group_id)) + | _ -> Ok ()); + (fun () -> + match group.limits.max_absolute_net_exposure with + | Some limit when Scalar.Money.compare absolute_net limit > 0 -> + Error + (Printf.sprintf + "position would exceed group %s maximum absolute net \ + exposure" + (Id.Risk_group.to_string group.group_id)) + | _ -> Ok ()); + (fun () -> + match group.limits.max_concentration with + | None -> Ok () + | Some limit -> + let* exceeded = concentration_exceeded limit in + if exceeded then + Error + (Printf.sprintf + "position would exceed group %s maximum concentration" + (Id.Risk_group.to_string group.group_id)) + else Ok ()); + ]) + (Ok ()) state.groups + +let check_reserved_fill state ~account ~oms ~marks ~fx_rates ~(order : Order.t) + ~filled_quantity ~after = + let instrument_id = order.request.instrument_id in + let* quantities = + fill_projected_quantities state ~account ~oms ~order ~filled_quantity + |> invalid + in + let* values = projected_values state ~marks ~fx_rates quantities |> invalid in + let* policy = + match instrument_policy state instrument_id with + | Some policy -> Ok policy + | None -> Error (Invalid "fill has no instrument risk policy") + in + let* () = + List.fold_left + (fun result value -> + let* () = result in + let* item_policy = + match instrument_policy state value.instrument_id with + | Some policy -> Ok policy + | None -> Error (Invalid "fill projection has no risk policy") in - let* rate = - match Currency_map.find_opt instrument.quote_currency fx_map with - | Some value -> Ok value - | None -> Error "projected position has no current FX rate" + if + Scalar.Quantity.compare value.quantity item_policy.max_long_position + > 0 + then + Error + (Limit + (Instrument_maximum_long_position + (value.instrument_id, item_policy.max_long_position))) + else + let* minimum_short = + Scalar.Quantity.negate item_policy.max_short_position |> invalid + in + if Scalar.Quantity.compare value.quantity minimum_short < 0 then + Error + (Limit + (Instrument_maximum_short_position + (value.instrument_id, item_policy.max_short_position))) + else if + (not item_policy.shorting_allowed) + && Scalar.Quantity.is_negative value.quantity + then Error (Limit (Instrument_shorting_disabled value.instrument_id)) + else + match item_policy.max_notional_exposure with + | Some limit + when Scalar.Money.compare value.absolute_value limit > 0 -> + Error + (Limit + (Instrument_maximum_notional (value.instrument_id, limit))) + | _ -> Ok ()) + (Ok ()) values + in + let* gross, _, _, _ = sum_values values |> invalid in + let* () = + if Scalar.Money.compare gross state.max_gross_exposure > 0 then + Error (Limit (Maximum_gross_exposure state.max_gross_exposure)) + else + let* leveraged_equity = + Scalar.Money.multiply_ratio after.Account.equity state.max_leverage + |> invalid + in + if Scalar.Money.compare gross leveraged_equity > 0 then + Error (Limit (Maximum_leverage state.max_leverage)) + else Ok () + in + let* initial_requirement = + List.fold_left + (fun result value -> + let* total = result in + let* item_policy = + match instrument_policy state value.instrument_id with + | Some policy -> Ok policy + | None -> Error (Invalid "fill projection has no risk policy") in - let* value = Scalar.Money.notional mark quantity in - let* value = Scalar.Money.absolute value in - let* value = Scalar.Money.convert value ~rate in - Scalar.Money.add gross value) - (Ok Scalar.Money.zero) quantities + let* requirement = + Scalar.Money.bps_ceil value.absolute_value + ~bps:item_policy.initial_margin_bps + |> invalid + in + Scalar.Money.add total requirement |> invalid) + (Ok Scalar.Money.zero) values + in + let* excess = + Scalar.Money.subtract after.equity initial_requirement |> invalid in - Ok gross_exposure + if Scalar.Money.compare excess Scalar.Money.zero < 0 then + Error + (Limit + (Instrument_initial_margin (instrument_id, policy.initial_margin_bps))) + else check_group_fill_limit state ~equity:after.equity values -let check state ~account ~oms ~marks ~fx_rates request = - if Scalar.Quantity.compare request.Order.quantity state.max_order_quantity > 0 - then Error "order exceeds the maximum order quantity" - else - match instrument state request.instrument_id with - | None -> Error "order refers to an unknown instrument" - | Some instrument -> +let check state ~account ~oms ~marks ~fx_rates (request : Order.request) = + match instrument state request.instrument_id with + | None -> Error "order refers to an unknown instrument" + | Some instrument -> + let* policy = + match instrument_policy state request.instrument_id with + | Some value -> Ok value + | None -> Error "order has no instrument risk policy" + in + if + Scalar.Quantity.compare request.Order.quantity policy.max_order_quantity + > 0 + then Error "order exceeds the instrument maximum order quantity" + else let* () = check_alignment instrument request in - let* pending = - working_position ~account ~oms request.Order.instrument_id - in - let* projected = projected_position ~account ~oms request in + let* () = check_self_cross ~oms request in + let* pending, projected = projected_position ~account ~oms request in let* pending_absolute = Scalar.Quantity.absolute pending in let* projected_absolute = Scalar.Quantity.absolute projected in if Scalar.Quantity.compare projected_absolute pending_absolute <= 0 then Ok () else + let* () = check_position_for state request.instrument_id projected in let* before = Account.value account ~instruments:(instruments state) ~marks ~fx_rates in - let* projected_gross_exposure = - projected_gross_exposure state ~account ~oms ~marks ~fx_rates - request + let* quantities = + projected_valuation_quantities state ~account ~oms request in - check_initial_values state ~equity:before.equity - ~gross_exposure:projected_gross_exposure + let* values = projected_values state ~marks ~fx_rates quantities in + check_projected_values state ~equity:before.equity values diff --git a/lib/risk.mli b/lib/risk.mli index 6f23d7e..6b0795c 100644 --- a/lib/risk.mli +++ b/lib/risk.mli @@ -2,30 +2,116 @@ type t +type instrument_policy = private { + instrument_id : Id.Instrument.t; + max_order_quantity : Scalar.Quantity.t; + max_long_position : Scalar.Quantity.t; + max_short_position : Scalar.Quantity.t; + max_notional_exposure : Scalar.Money.t option; + initial_margin_bps : int; + maintenance_margin_bps : int; + shorting_allowed : bool; +} + +type group_kind = Issuer | Sector | Currency | Country | Asset_class | Custom + +type group_limits = private { + max_gross_exposure : Scalar.Money.t option; + max_long_exposure : Scalar.Money.t option; + max_short_exposure : Scalar.Money.t option; + max_absolute_net_exposure : Scalar.Money.t option; + max_concentration : Scalar.Ratio.t option; +} + +type group = private { + group_id : Id.Risk_group.t; + group_kind : group_kind; + instrument_ids : Id.Instrument.t list; + limits : group_limits; +} + +type group_exposure = private { + group_id : Id.Risk_group.t; + gross_exposure : Scalar.Money.t; + net_exposure : Scalar.Money.t; + long_exposure : Scalar.Money.t; + short_exposure : Scalar.Money.t; + concentration : Scalar.Weight.t option; +} + type margin_snapshot = private { initial_requirement : Scalar.Money.t; maintenance_requirement : Scalar.Money.t; initial_excess : Scalar.Money.t; maintenance_excess : Scalar.Money.t; margin_call : bool; + group_exposures : group_exposure list; } +type fill_limit = + | Maximum_order_quantity of Scalar.Quantity.t + | Maximum_long_position of Scalar.Quantity.t + | Maximum_short_position of Scalar.Quantity.t + | Maximum_gross_exposure of Scalar.Money.t + | Maximum_leverage of Scalar.Ratio.t + | Initial_margin of int + | Instrument_maximum_long_position of Id.Instrument.t * Scalar.Quantity.t + | Instrument_maximum_short_position of Id.Instrument.t * Scalar.Quantity.t + | Instrument_maximum_notional of Id.Instrument.t * Scalar.Money.t + | Instrument_shorting_disabled of Id.Instrument.t + | Instrument_borrow_availability of Id.Instrument.t * Scalar.Quantity.t + | Settlement_cash_buying_power of string * Scalar.Money.t + | Settlement_position_availability of Id.Instrument.t * Scalar.Quantity.t + | Instrument_initial_margin of Id.Instrument.t * int + | Group_maximum_gross of Id.Risk_group.t * Scalar.Money.t + | Group_maximum_long of Id.Risk_group.t * Scalar.Money.t + | Group_maximum_short of Id.Risk_group.t * Scalar.Money.t + | Group_maximum_absolute_net of Id.Risk_group.t * Scalar.Money.t + | Group_maximum_concentration of Id.Risk_group.t * Scalar.Ratio.t + +type fill_check_error = Limit of fill_limit | Invalid of string + val create : base_currency:string -> instruments:Instrument.t list -> + instrument_policies:instrument_policy list -> + groups:group list -> + max_gross_exposure:Scalar.Money.t -> + max_leverage:Scalar.Ratio.t -> + (t, string) result + +val create_instrument_policy : + instrument:Instrument.t -> max_order_quantity:Scalar.Quantity.t -> max_long_position:Scalar.Quantity.t -> max_short_position:Scalar.Quantity.t -> - max_gross_exposure:Scalar.Money.t -> - max_leverage:Scalar.Ratio.t -> + max_notional_exposure:Scalar.Money.t option -> initial_margin_bps:int -> maintenance_margin_bps:int -> - short_borrow_bps:int -> - (t, string) result + shorting_allowed:bool -> + (instrument_policy, string) result + +val create_group_limits : + max_gross_exposure:Scalar.Money.t option -> + max_long_exposure:Scalar.Money.t option -> + max_short_exposure:Scalar.Money.t option -> + max_absolute_net_exposure:Scalar.Money.t option -> + max_concentration:Scalar.Ratio.t option -> + (group_limits, string) result + +val create_group : + group_id:Id.Risk_group.t -> + group_kind:group_kind -> + instrument_ids:Id.Instrument.t list -> + limits:group_limits -> + (group, string) result val base_currency : t -> string val instruments : t -> Instrument.t list val instrument : t -> Id.Instrument.t -> Instrument.t option +val instrument_policies : t -> instrument_policy list +val instrument_policy : t -> Id.Instrument.t -> instrument_policy option +val groups : t -> group list val max_order_quantity : t -> Scalar.Quantity.t val max_long_position : t -> Scalar.Quantity.t val max_short_position : t -> Scalar.Quantity.t @@ -33,16 +119,46 @@ val max_gross_exposure : t -> Scalar.Money.t val max_leverage : t -> Scalar.Ratio.t val initial_margin_bps : t -> int val maintenance_margin_bps : t -> int -val short_borrow_bps : t -> int +val max_order_quantity_for : t -> Id.Instrument.t -> Scalar.Quantity.t option val check_position : t -> Scalar.Quantity.t -> (unit, string) result + +val check_position_for : + t -> Id.Instrument.t -> Scalar.Quantity.t -> (unit, string) result + val margin_snapshot : t -> Account.valuation -> (margin_snapshot, string) result + +val group_exposures : + t -> Account.valuation -> (group_exposure list, string) result + val check_initial : t -> Account.valuation -> (unit, string) result val check_post_fill : t -> + before_position:Scalar.Quantity.t -> + after_position:Scalar.Quantity.t -> before:Account.valuation -> after:Account.valuation -> - (unit, string) result + (unit, fill_check_error) result + +val check_post_fill_for : + t -> + instrument_id:Id.Instrument.t -> + before_position:Scalar.Quantity.t -> + after_position:Scalar.Quantity.t -> + before:Account.valuation -> + after:Account.valuation -> + (unit, fill_check_error) result + +val check_reserved_fill : + t -> + account:Account.t -> + oms:Oms.t -> + marks:(Id.Instrument.t * Scalar.Price.t) list -> + fx_rates:(string * Scalar.Price.t) list -> + order:Order.t -> + filled_quantity:Scalar.Quantity.t -> + after:Account.valuation -> + (unit, fill_check_error) result val check : t -> diff --git a/lib/scenario.ml b/lib/scenario.ml index 2d1bdf4..54d4607 100644 --- a/lib/scenario.ml +++ b/lib/scenario.ml @@ -3,11 +3,14 @@ type t = { metadata : Yojson.Safe.t; run_id : Id.Run.t; base_currency : string; - initial_cash : (string * Scalar.Money.t) list; + initial_portfolio : Initial_portfolio.t; instruments : Instrument.t list; + venue_calendars : Venue_calendar.t list; risk : Risk.t; execution_model : Execution_model.t; execution : Execution.t; + financing : Financing.policy; + settlement : Settlement.policy; max_internal_events : int; schedule : (int64 * Strategy.intent list) list; slices : Market_slice.t list; @@ -18,11 +21,14 @@ type stream_header = { metadata : Yojson.Safe.t; run_id : Id.Run.t; base_currency : string; - initial_cash : (string * Scalar.Money.t) list; + initial_portfolio : Initial_portfolio.t; instruments : Instrument.t list; + venue_calendars : Venue_calendar.t list; risk : Risk.t; execution_model : Execution_model.t; execution : Execution.t; + financing : Financing.policy; + settlement : Settlement.policy; max_internal_events : int; } @@ -32,12 +38,70 @@ type stream_item = { action_ids : Id.Corporate_action.Set.t; } -module Int64_set = Set.Make (Int64) -module String_set = Set.Make (String) - let ( let* ) result function_ = match result with Ok value -> function_ value | Error _ as error -> error +let resource_limit ~json_path ~name ~observed ~allowed = + Diagnostic.make ~code:Diagnostic.Resource_limit ~phase:Diagnostic.Validation + ~json_path + (Printf.sprintf "%s count is %d; limit is %d" name observed allowed) + +let check_list_limit fields field_name ~json_path ~name allowed = + match List.assoc_opt field_name fields with + | Some (`List values) when List.length values > allowed -> + Error + (resource_limit ~json_path ~name ~observed:(List.length values) ~allowed) + | _ -> Ok () + +let check_internal_event_limit fields ~json_path = + match List.assoc_opt "max_internal_events" fields with + | Some (`Int observed) when observed > Resource_limits.internal_events -> + Error + (resource_limit ~json_path ~name:"internal event" ~observed + ~allowed:Resource_limits.internal_events) + | _ -> Ok () + +let check_batch_limits = function + | `Assoc fields -> ( + let* () = + check_list_limit fields "instruments" ~json_path:"$.instruments" + ~name:"catalog instrument" Resource_limits.catalog_instruments + in + let* () = + check_internal_event_limit fields ~json_path:"$.max_internal_events" + in + match List.assoc_opt "schedule" fields with + | Some (`List items) -> + let rec check index = function + | [] -> Ok () + | `Assoc item_fields :: remaining -> + let* () = + check_list_limit item_fields "intents" + ~json_path:(Printf.sprintf "$.schedule[%d].intents" index) + ~name:"intent" Resource_limits.intents_per_batch + in + check (index + 1) remaining + | _ :: remaining -> check (index + 1) remaining + in + check 0 items + | _ -> Ok ()) + | _ -> Ok () + +let check_stream_header_limits = function + | `Assoc fields -> + let* () = + check_list_limit fields "instruments" ~json_path:"$.instruments" + ~name:"catalog instrument" Resource_limits.catalog_instruments + in + check_internal_event_limit fields ~json_path:"$.max_internal_events" + | _ -> Ok () + +let check_stream_item_limits = function + | `Assoc fields -> + check_list_limit fields "intents" ~json_path:"$.intents" ~name:"intent" + Resource_limits.intents_per_batch + | _ -> Ok () + let object_fields ~name ~expected = function | `Assoc fields -> let names = List.map fst fields in @@ -91,6 +155,22 @@ let map_list parse values = in List.fold_left step (Ok []) values |> Result.map List.rev +let at json_path result = + Result.map_error + (fun message -> Scenario_shape.error ~json_path message) + result + +let map_list_at root parse values = + let step result (index, value) = + let* values = result in + let* value = parse value |> at (Printf.sprintf "%s[%d]" root index) in + Ok (value :: values) + in + values + |> List.mapi (fun index value -> (index, value)) + |> List.fold_left step (Ok []) + |> Result.map List.rev + let parse_id parse ~name json = let* value = string ~name json in parse value @@ -138,6 +218,70 @@ let parse_timestamp ~name json = let* value = string ~name json in Codec.ptime_of_string value +let parse_venue_phase json = + let* fields = + object_fields ~name:"venue phase" + ~expected:[ "phase"; "opens_at"; "closes_at" ] + json + in + let* kind_json = field fields "phase" in + let* kind_name = string ~name:"venue phase" kind_json in + let* kind = Venue_calendar.phase_kind_of_string kind_name in + let* opens_json = field fields "opens_at" in + let* opens_at = parse_timestamp ~name:"venue phase opens_at" opens_json in + let* closes_json = field fields "closes_at" in + let* closes_at = parse_timestamp ~name:"venue phase closes_at" closes_json in + Venue_calendar.create_phase ~kind ~opens_at ~closes_at + +let parse_venue_session json = + let* fields = + object_fields ~name:"venue session policy" + ~expected:[ "session_date"; "policy"; "phases" ] + json + in + let* date_json = field fields "session_date" in + let* session_date = string ~name:"session_date" date_json in + let* policy_json = field fields "policy" in + let* policy_name = string ~name:"session policy" policy_json in + let* kind = Venue_calendar.session_kind_of_string policy_name in + let* phases_json = field fields "phases" in + let* phases_json = list ~name:"venue phases" phases_json in + let* phases = map_list parse_venue_phase phases_json in + Venue_calendar.create_session ~session_date ~kind ~phases + +let parse_venue_calendar json = + let* fields = + object_fields ~name:"venue calendar" + ~expected: + [ + "calendar_id"; + "calendar_version"; + "venue_id"; + "instrument_ids"; + "sessions"; + ] + json + in + let* id_json = field fields "calendar_id" in + let* id = parse_id Id.Venue_calendar.of_string ~name:"calendar_id" id_json in + let* version_json = field fields "calendar_version" in + let* version = string ~name:"calendar_version" version_json in + let* venue_json = field fields "venue_id" in + let* venue_id = parse_id Id.Venue.of_string ~name:"venue_id" venue_json in + let* instruments_json = field fields "instrument_ids" in + let* instruments_json = + list ~name:"calendar instrument_ids" instruments_json + in + let* instrument_ids = + map_list + (parse_id Id.Instrument.of_string ~name:"calendar instrument_id") + instruments_json + in + let* sessions_json = field fields "sessions" in + let* sessions_json = list ~name:"venue sessions" sessions_json in + let* sessions = map_list parse_venue_session sessions_json in + Venue_calendar.create ~id ~version ~venue_id ~instrument_ids ~sessions + let rec validate_metadata = function | `Assoc fields -> let names = List.map fst fields in @@ -181,67 +325,412 @@ let parse_instrument json = let* lot_size = parse_quantity ~name:"lot_size" lot_json in Instrument.create ~id ~symbol ~quote_currency ~tick_size ~lot_size -let parse_risk base_currency instruments json = +let parse_nullable parse ~name = function + | `Null -> Ok None + | json -> parse ~name json |> Result.map Option.some + +let parse_group_kind = function + | "issuer" -> Ok Risk.Issuer + | "sector" -> Ok Risk.Sector + | "currency" -> Ok Risk.Currency + | "country" -> Ok Risk.Country + | "asset_class" -> Ok Risk.Asset_class + | "custom" -> Ok Risk.Custom + | _ -> Error "group_type is unsupported" + +let parse_instrument_policy instrument_map json = let* fields = - object_fields ~name:"risk" + object_fields ~name:"instrument risk policy" ~expected: [ + "instrument_id"; "max_order_quantity"; "max_long_position"; "max_short_position"; - "max_gross_exposure"; - "max_leverage"; + "max_notional_exposure"; "initial_margin_bps"; "maintenance_margin_bps"; - "short_borrow_bps"; + "shorting_allowed"; ] json in - let* order_json = field fields "max_order_quantity" in + let* id_json = field fields "instrument_id" in + let* instrument_id = + parse_id Id.Instrument.of_string ~name:"instrument_id" id_json + in + let* instrument = + match Id.Instrument.Map.find_opt instrument_id instrument_map with + | Some instrument -> Ok instrument + | None -> Error "instrument policy refers to an unknown instrument" + in let* max_order_quantity = - parse_quantity ~name:"max_order_quantity" order_json + field fields "max_order_quantity" |> fun result -> + Result.bind result (parse_quantity ~name:"max_order_quantity") + in + let* max_long_position = + field fields "max_long_position" |> fun result -> + Result.bind result (parse_quantity ~name:"max_long_position") in - let* long_json = field fields "max_long_position" in - let* max_long_position = parse_quantity ~name:"max_long_position" long_json in - let* short_json = field fields "max_short_position" in let* max_short_position = - parse_quantity ~name:"max_short_position" short_json - in - let* gross_json = field fields "max_gross_exposure" in - let* max_gross_exposure = parse_money ~name:"max_gross_exposure" gross_json in - let* leverage_json = field fields "max_leverage" in - let* max_leverage = parse_ratio ~name:"max_leverage" leverage_json in - let* initial_json = field fields "initial_margin_bps" in - let* initial_margin_bps = integer ~name:"initial_margin_bps" initial_json in - let* maintenance_json = field fields "maintenance_margin_bps" in + field fields "max_short_position" |> fun result -> + Result.bind result (parse_quantity ~name:"max_short_position") + in + let* max_notional_exposure = + field fields "max_notional_exposure" |> fun result -> + Result.bind result (parse_money ~name:"max_notional_exposure") + in + let* initial_margin_bps = + field fields "initial_margin_bps" |> fun result -> + Result.bind result (integer ~name:"initial_margin_bps") + in let* maintenance_margin_bps = - integer ~name:"maintenance_margin_bps" maintenance_json + field fields "maintenance_margin_bps" |> fun result -> + Result.bind result (integer ~name:"maintenance_margin_bps") in - let* borrow_json = field fields "short_borrow_bps" in - let* short_borrow_bps = integer ~name:"short_borrow_bps" borrow_json in - Risk.create ~base_currency ~instruments ~max_order_quantity ~max_long_position - ~max_short_position ~max_gross_exposure ~max_leverage ~initial_margin_bps - ~maintenance_margin_bps ~short_borrow_bps + let* shorting_allowed = + match List.assoc "shorting_allowed" fields with + | `Bool value -> Ok value + | _ -> Error "shorting_allowed must be a boolean" + in + Risk.create_instrument_policy ~instrument ~max_order_quantity + ~max_long_position ~max_short_position + ~max_notional_exposure:(Some max_notional_exposure) ~initial_margin_bps + ~maintenance_margin_bps ~shorting_allowed -let parse_execution json = +let parse_group json = let* fields = - object_fields ~name:"execution" - ~expected:[ "model"; "participation_bps"; "fixed_fee"; "fee_bps" ] + object_fields ~name:"risk group" + ~expected: + [ + "group_id"; "group_version"; "group_type"; "instrument_ids"; "limits"; + ] json in - let* model_json = field fields "model" in - let* model_name = string ~name:"execution model" model_json in - let* execution_model = Execution_model.find model_name in + let* group_id = + field fields "group_id" |> fun result -> + Result.bind result (parse_id Id.Risk_group.of_string ~name:"group_id") + in + let* group_version = + field fields "group_version" |> fun result -> + Result.bind result (string ~name:"group_version") + in + let* () = + if String.equal group_version "1" then Ok () + else Error "group_version must be 1" + in + let* group_kind = + field fields "group_type" |> fun result -> + Result.bind result (string ~name:"group_type") |> fun result -> + Result.bind result parse_group_kind + in + let* instrument_ids_json = + field fields "instrument_ids" |> fun result -> + Result.bind result (list ~name:"instrument_ids") + in + let* instrument_ids = + map_list + (parse_id Id.Instrument.of_string ~name:"instrument_id") + instrument_ids_json + in + let* limits_json = field fields "limits" in + let* limits_fields = + object_fields ~name:"risk group limits" + ~expected: + [ + "max_gross_exposure"; + "max_long_exposure"; + "max_short_exposure"; + "max_absolute_net_exposure"; + "max_concentration"; + ] + limits_json + in + let money_limit name = + field limits_fields name |> fun result -> + Result.bind result (parse_nullable parse_money ~name) + in + let* max_gross_exposure = money_limit "max_gross_exposure" in + let* max_long_exposure = money_limit "max_long_exposure" in + let* max_short_exposure = money_limit "max_short_exposure" in + let* max_absolute_net_exposure = money_limit "max_absolute_net_exposure" in + let* max_concentration = + field limits_fields "max_concentration" |> fun result -> + Result.bind result (parse_nullable parse_ratio ~name:"max_concentration") + in + let* limits = + Risk.create_group_limits ~max_gross_exposure ~max_long_exposure + ~max_short_exposure ~max_absolute_net_exposure ~max_concentration + in + Risk.create_group ~group_id ~group_kind ~instrument_ids ~limits + +let parse_risk base_currency instruments json = + let* fields = + object_fields ~name:"risk" + ~expected: + [ + "max_gross_exposure"; "max_leverage"; "instrument_policies"; "groups"; + ] + json + in + let instrument_map = + List.fold_left + (fun map instrument -> + Id.Instrument.Map.add instrument.Instrument.id instrument map) + Id.Instrument.Map.empty instruments + in + let* policies_json = + field fields "instrument_policies" |> fun result -> + Result.bind result (list ~name:"instrument_policies") + in + let* instrument_policies = + map_list (parse_instrument_policy instrument_map) policies_json + in + let* groups_json = + field fields "groups" |> fun result -> + Result.bind result (list ~name:"groups") + in + let* groups = map_list parse_group groups_json in + let* max_gross_exposure = + field fields "max_gross_exposure" |> fun result -> + Result.bind result (parse_money ~name:"max_gross_exposure") + in + let* max_leverage = + field fields "max_leverage" |> fun result -> + Result.bind result (parse_ratio ~name:"max_leverage") + in + Risk.create ~base_currency ~instruments ~instrument_policies ~groups + ~max_gross_exposure ~max_leverage + +let parse_fee_component json = + let* fields = + object_fields ~name:"fee component" + ~expected: + [ "name"; "currency"; "kind"; "value"; "rounding"; "applies_to" ] + json + in + let* name_json = field fields "name" in + let* name = string ~name:"fee component name" name_json in + let* currency_json = field fields "currency" in + let* currency = string ~name:"fee component currency" currency_json in + let* kind_json = field fields "kind" in + let* kind = string ~name:"fee component kind" kind_json in + let* value = field fields "value" in + let* basis = + match kind with + | "fixed" -> + Result.map + (fun value -> Fee_schedule.Fixed value) + (parse_money ~name:"fixed fee value" value) + | "notional_bps" -> + Result.map + (fun value -> Fee_schedule.Notional_bps value) + (integer ~name:"notional fee basis points" value) + | "per_unit" -> + Result.map + (fun value -> Fee_schedule.Per_unit value) + (parse_money ~name:"per-unit fee value" value) + | value -> Error (Printf.sprintf "unsupported fee component kind %S" value) + in + let* rounding_json = field fields "rounding" in + let* rounding_name = string ~name:"fee rounding" rounding_json in + let* rounding = Fee_schedule.rounding_of_string rounding_name in + let* applicability_json = field fields "applies_to" in + let* applicability_name = + string ~name:"fee applicability" applicability_json + in + let* applicability = + Fee_schedule.applicability_of_string applicability_name + in + Fee_schedule.create_component ~name ~currency ~basis ~rounding ~applicability + +let parse_optional_money ~name = function + | `Null -> Ok None + | json -> Result.map Option.some (parse_money ~name json) + +let parse_fee_schedule instrument_ids json = + let* fields = + object_fields ~name:"fee schedule" + ~expected: + [ + "schedule_id"; + "instrument_id"; + "settlement_currency"; + "minimum"; + "maximum"; + "components"; + ] + json + in + let* schedule_id_json = field fields "schedule_id" in + let* schedule_id = string ~name:"fee schedule ID" schedule_id_json in + let* instrument_id_json = field fields "instrument_id" in + let* instrument_id = + parse_id Id.Instrument.of_string ~name:"fee schedule instrument_id" + instrument_id_json + in + let* () = + if Id.Instrument.Set.mem instrument_id instrument_ids then Ok () + else Error "fee schedule refers to an unknown instrument" + in + let* settlement_currency_json = field fields "settlement_currency" in + let* settlement_currency = + string ~name:"fee settlement currency" settlement_currency_json + in + let* minimum_json = field fields "minimum" in + let* minimum = parse_optional_money ~name:"fee minimum" minimum_json in + let* maximum_json = field fields "maximum" in + let* maximum = parse_optional_money ~name:"fee maximum" maximum_json in + let* components_value = field fields "components" in + let* components_json = list ~name:"fee components" components_value in + let* components = map_list parse_fee_component components_json in + Fee_schedule.create ~schedule_id ~instrument_id ~settlement_currency ~minimum + ~maximum ~components + +let parse_execution_common instruments fields = let* participation_json = field fields "participation_bps" in let* participation_bps = integer ~name:"participation_bps" participation_json in - let* fixed_json = field fields "fixed_fee" in - let* fixed_fee = parse_money ~name:"fixed_fee" fixed_json in - let* fee_json = field fields "fee_bps" in - let* fee_bps = integer ~name:"fee_bps" fee_json in - let* execution = Execution.create ~participation_bps ~fixed_fee ~fee_bps in - Ok (execution_model, execution) + let instrument_ids = + List.fold_left + (fun ids instrument -> Id.Instrument.Set.add instrument.Instrument.id ids) + Id.Instrument.Set.empty instruments + in + let* schedules_value = field fields "fee_schedules" in + let* schedules_json = list ~name:"fee_schedules" schedules_value in + let* schedules = + map_list (parse_fee_schedule instrument_ids) schedules_json + in + let scheduled = + List.map Fee_schedule.instrument_id schedules + |> List.sort_uniq Id.Instrument.compare + in + let expected = Id.Instrument.Set.elements instrument_ids in + let* () = + if scheduled = expected then Ok () + else + Error "fee schedules must cover every configured instrument exactly once" + in + Ok (participation_bps, schedules) + +let parse_scheduled_execution instruments fields = + let* participation_bps, schedules = + parse_execution_common instruments fields + in + Execution.create ~participation_bps ~fee_schedules:schedules + +let parse_order_book_execution instruments fields = + let* participation_bps, fee_schedules = + parse_execution_common instruments fields + in + let* max_depth_levels = + Result.bind + (field fields "max_depth_levels") + (integer ~name:"max_depth_levels") + in + Execution.create_order_book ~participation_bps ~fee_schedules + ~max_depth_levels + +let parse_conservative_execution instruments fields = + let* participation_bps, fee_schedules = + parse_execution_common instruments fields + in + let* spread_json = field fields "spread_model" in + let* spread_fields = + object_fields ~name:"spread model" + ~expected:[ "model"; "half_spread_bps" ] + spread_json + in + let* spread_name = + Result.bind (field spread_fields "model") (string ~name:"spread model") + in + let* () = + if String.equal spread_name "fixed_half_spread_v1" then Ok () + else Error "unsupported spread model" + in + let* half_spread_bps = + Result.bind + (field spread_fields "half_spread_bps") + (integer ~name:"half_spread_bps") + in + let* impact_json = field fields "impact_model" in + let* impact_fields = + object_fields ~name:"impact model" + ~expected:[ "model"; "coefficient_bps"; "missing_volume_policy" ] + impact_json + in + let* impact_name = + Result.bind (field impact_fields "model") (string ~name:"impact model") + in + let* () = + if String.equal impact_name "linear_participation_v1" then Ok () + else Error "unsupported impact model" + in + let* impact_coefficient_bps = + Result.bind + (field impact_fields "coefficient_bps") + (integer ~name:"impact coefficient_bps") + in + let* missing_name = + Result.bind + (field impact_fields "missing_volume_policy") + (string ~name:"missing_volume_policy") + in + let* missing_volume_policy = + match missing_name with + | "reject" -> Ok Execution.Reject_missing_volume + | "zero_impact" -> Ok Execution.Zero_impact + | _ -> Error "missing_volume_policy must be reject or zero_impact" + in + Execution.create_conservative ~participation_bps ~fee_schedules + ~half_spread_bps ~impact_coefficient_bps ~missing_volume_policy + +let parse_execution ~contract_version ~instruments json = + let* fields = + object_fields ~name:"execution" ~expected:[ "model"; "configuration" ] json + in + let* model_json = field fields "model" in + let* model_name = string ~name:"execution model" model_json in + let* execution_model = Execution_model.find model_name in + let* () = + if Execution_model.supports_contract execution_model contract_version then + Ok () + else + Error + (Printf.sprintf + "execution model %S does not support scenario contract %S" model_name + contract_version) + in + let* configuration_json = field fields "configuration" in + let* loose_fields = + match configuration_json with + | `Assoc fields -> Ok fields + | _ -> Error (model_name ^ " execution configuration must be a JSON object") + in + let* version_json = field loose_fields "version" in + let* version = string ~name:"execution configuration version" version_json in + let* expected = Execution_model.required_fields execution_model version in + let* configuration = + object_fields + ~name:(model_name ^ " execution configuration") + ~expected configuration_json + in + if not (Execution_model.supports_configuration execution_model version) then + Error + (Printf.sprintf + "unsupported execution configuration version %S for model %S" version + model_name) + else + let* execution = + if + List.mem model_name + [ "completed_bar_next_open_v1"; "completed_bar_adverse_touch_v1" ] + then parse_conservative_execution instruments configuration + else if String.equal model_name "order_book_v1" then + parse_order_book_execution instruments configuration + else parse_scheduled_execution instruments configuration + in + Ok (execution_model, execution) let parse_side json = let* value = string ~name:"side" json in @@ -297,7 +786,12 @@ let parse_submit_intent json = "side"; "quantity"; "order_kind"; + "trigger_price"; "limit_price"; + "time_in_force"; + "venue_id"; + "calendar_id"; + "expires_at"; ] json in @@ -312,17 +806,49 @@ let parse_submit_intent json = let* kind_json = field fields "order_kind" in let* kind_name = string ~name:"order_kind" kind_json in let* limit_json = field fields "limit_price" in + let* trigger_json = field fields "trigger_price" in let* kind = - match (kind_name, limit_json) with - | "market", `Null -> Ok Order.Market - | "limit", value -> + match (kind_name, trigger_json, limit_json) with + | "market", `Null, `Null -> Ok Order.Market + | "limit", `Null, value -> let* limit = parse_price ~name:"limit_price" value in Ok (Order.Limit limit) - | "market", _ -> Error "market order limit_price must be null" + | "stop", trigger, `Null -> + let* trigger = parse_price ~name:"trigger_price" trigger in + Ok (Order.Stop trigger) + | "stop_limit", trigger, limit -> + let* trigger_price = parse_price ~name:"trigger_price" trigger in + let* limit_price = parse_price ~name:"limit_price" limit in + Ok (Order.Stop_limit { trigger_price; limit_price }) + | "market", _, _ -> + Error "market order trigger_price and limit_price must be null" | _ -> Error "invalid order_kind" in + let* tif_json = field fields "time_in_force" in + let* tif = string ~name:"time_in_force" tif_json in + let* venue_json = field fields "venue_id" in + let* calendar_json = field fields "calendar_id" in + let* expires_json = field fields "expires_at" in + let* time_in_force = + match (tif, venue_json, calendar_json, expires_json) with + | "gtc", `Null, `Null, `Null -> Ok Order.Gtc + | "ioc", `Null, `Null, `Null -> Ok Order.Ioc + | "fok", `Null, `Null, `Null -> Ok Order.Fok + | "day", venue, calendar, `Null -> + let* venue_id = parse_id Id.Venue.of_string ~name:"venue_id" venue in + let* calendar_id = + parse_id Id.Venue_calendar.of_string ~name:"calendar_id" calendar + in + Ok (Order.Day { venue_id; calendar_id }) + | "gtd", `Null, `Null, expires -> + let* value = string ~name:"expires_at" expires in + let* expires_at = Codec.ptime_of_string value in + Ok (Order.Gtd expires_at) + | _ -> Error "time_in_force companion fields are inconsistent" + in let* request = - Order.request ~instrument_id ~side ~quantity ~kind ~origin:Order.Direct + Order.request ~instrument_id ~side ~quantity ~kind ~time_in_force + ~origin:Order.Direct in Ok (Strategy.Submit_order request) @@ -337,15 +863,69 @@ let parse_cancel_intent json = let parse_metric_intent json = let* fields = - object_fields ~name:"emit_metric intent" - ~expected:[ "type"; "name"; "value" ] - json + match json with + | `Assoc fields -> + let names = List.map fst fields in + let unique = List.sort_uniq String.compare names in + let allowed = + [ "aggregation"; "dimensions"; "name"; "type"; "unit"; "value" ] + in + if List.length names <> List.length unique then + Error "emit_metric intent must not contain duplicate fields" + else if + not + (List.for_all (fun name -> List.mem name allowed) unique + && List.for_all + (fun name -> List.mem name unique) + [ "type"; "name"; "value" ]) + then Error "emit_metric intent has unknown or missing fields" + else Ok fields + | _ -> Error "emit_metric intent must be a JSON object" in let* name_json = field fields "name" in let* name = string ~name:"metric name" name_json in - let* value_json = field fields "value" in - let* value = string ~name:"metric value" value_json in - Ok (Strategy.Emit_metric { name; value }) + let* value = + let* json = field fields "value" in + let* value_fields = + object_fields ~name:"metric value" ~expected:[ "type"; "value" ] json + in + let* type_json = field value_fields "type" in + let* value_type = string ~name:"metric value type" type_json in + let* value_json = field value_fields "value" in + match (value_type, value_json) with + | "numeric", `String value -> + Metric.numeric_of_string value + |> Result.map (fun value -> Metric.Numeric value) + | "string", `String value -> Ok (Metric.String value) + | "boolean", `Bool value -> Ok (Metric.Boolean value) + | _ -> Error "metric value does not match its declared type" + in + let* unit_ = + match List.assoc_opt "unit" fields with + | None -> Ok None + | Some json -> string ~name:"metric unit" json |> Result.map Option.some + in + let* dimensions = + match List.assoc_opt "dimensions" fields with + | None -> Ok [] + | Some (`Assoc dimensions) -> + List.fold_left + (fun result (key, json) -> + let* values = result in + let* value = string ~name:"metric dimension value" json in + Ok ((key, value) :: values)) + (Ok []) dimensions + | Some _ -> Error "metric dimensions must be an object" + in + let* aggregation = + match List.assoc_opt "aggregation" fields with + | None -> Ok None + | Some json -> + let* value = string ~name:"metric aggregation" json in + Metric.aggregation_of_string value |> Result.map Option.some + in + let* metric = Metric.create ~name ~value ?unit_ ~dimensions ?aggregation () in + Ok (Strategy.Emit_metric metric) let parse_intent json = match json with @@ -368,7 +948,11 @@ let parse_intent json = | None -> Error "intent is missing type") | _ -> Error "intent must be a JSON object" -let intent_of_yojson = parse_intent +let intent_of_yojson json = + parse_intent json + |> Result.map_error (fun message -> + Diagnostic.make ~code:Diagnostic.Scenario_invalid + ~phase:Diagnostic.Validation ~json_path:"$" message) let parse_schedule_item json = let* fields = @@ -380,8 +964,13 @@ let parse_schedule_item json = let* sequence = parse_int64 ~name:"after_slice_sequence" sequence_json in let* intents_json = field fields "intents" in let* intents_json = list ~name:"intents" intents_json in - let* intents = map_list parse_intent intents_json in - Ok (sequence, intents) + if List.length intents_json > Resource_limits.intents_per_batch then + Error + (Printf.sprintf "intent count is %d; limit is %d" + (List.length intents_json) Resource_limits.intents_per_batch) + else + let* intents = map_list parse_intent intents_json in + Ok (sequence, intents) let parse_volume = function | `Null -> Ok None @@ -420,6 +1009,92 @@ let parse_fx_mark json = let* rate = parse_price ~name:"FX rate" rate_json in Market_slice.fx_mark ~currency ~rate +let parse_initial_position json = + let* fields = + object_fields ~name:"initial position" + ~expected: + [ + "instrument_id"; + "quantity"; + "cost_basis"; + "realized_pnl"; + "dividend_pnl"; + "execution_fees"; + "borrow_fees"; + ] + json + in + let* instrument_json = field fields "instrument_id" in + let* instrument_id = + parse_id Id.Instrument.of_string ~name:"instrument_id" instrument_json + in + let* quantity_json = field fields "quantity" in + let* quantity = + parse_quantity ~name:"initial position quantity" quantity_json + in + let* basis_json = field fields "cost_basis" in + let* cost_basis = + parse_money ~name:"initial position cost_basis" basis_json + in + let* realized_json = field fields "realized_pnl" in + let* realized_pnl = + parse_money ~name:"initial position realized_pnl" realized_json + in + let* dividend_json = field fields "dividend_pnl" in + let* dividend_pnl = + parse_money ~name:"initial position dividend_pnl" dividend_json + in + let* execution_json = field fields "execution_fees" in + let* execution_fees = + parse_money ~name:"initial position execution_fees" execution_json + in + let* borrow_json = field fields "borrow_fees" in + let* borrow_fees = + parse_money ~name:"initial position borrow_fees" borrow_json + in + Initial_portfolio.position ~instrument_id ~quantity ~cost_basis ~realized_pnl + ~dividend_pnl ~execution_fees ~borrow_fees + +let parse_initial_mark json = + let* fields = + object_fields ~name:"initial mark" + ~expected:[ "instrument_id"; "price" ] + json + in + let* instrument_json = field fields "instrument_id" in + let* instrument_id = + parse_id Id.Instrument.of_string ~name:"instrument_id" instrument_json + in + let* price_json = field fields "price" in + let* price = parse_price ~name:"initial mark price" price_json in + Ok (instrument_id, price) + +let parse_initial_fx_rate json = + let* mark = parse_fx_mark json in + Ok (mark.Market_slice.currency, mark.rate) + +let parse_initial_portfolio ~base_currency json = + let* fields = + object_fields ~name:"initial portfolio" + ~expected:[ "cash"; "positions"; "marks"; "fx_rates" ] + json + in + let* cash_json = field fields "cash" in + let* cash_json = list ~name:"initial portfolio cash" cash_json in + let* cash = map_list parse_cash_balance cash_json in + let* positions_json = field fields "positions" in + let* positions_json = + list ~name:"initial portfolio positions" positions_json + in + let* positions = map_list parse_initial_position positions_json in + let* marks_json = field fields "marks" in + let* marks_json = list ~name:"initial portfolio marks" marks_json in + let* marks = map_list parse_initial_mark marks_json in + let* fx_json = field fields "fx_rates" in + let* fx_json = list ~name:"initial portfolio FX rates" fx_json in + let* fx_rates = map_list parse_initial_fx_rate fx_json in + Initial_portfolio.create ~base_currency ~cash ~positions ~marks ~fx_rates + let parse_corporate_action json = let* fields = match json with @@ -460,8 +1135,654 @@ 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" + ~expected: + [ + "day_count"; + "compounding"; + "borrow_missing_data"; + "cash_missing_data"; + "locate_policy"; + "recall_policy"; + ] + json + in + let text name = Result.bind (field fields name) (string ~name) in + let* day_count = + match text "day_count" with + | Ok "actual_365" -> Ok Financing.Actual_365 + | Ok "actual_360" -> Ok Financing.Actual_360 + | Ok _ -> Error "day_count must be actual_365 or actual_360" + | Error _ as error -> error + in + let* compounding = + match text "compounding" with + | Ok "simple" -> Ok Financing.Simple + | Ok "daily" -> Ok Financing.Daily + | Ok _ -> Error "compounding must be simple or daily" + | Error _ as error -> error + in + let missing name = + match text name with + | Ok "reject" -> Ok Financing.Reject + | Ok "zero" -> Ok Financing.Zero + | Ok _ -> Error (name ^ " must be reject or zero") + | Error _ as error -> error + in + let* borrow_missing_data = missing "borrow_missing_data" in + let* cash_missing_data = missing "cash_missing_data" in + let* locate_policy = + match text "locate_policy" with + | Ok "reject_order" -> Ok Financing.Reject_order + | Ok "clip_fill" -> Ok Financing.Clip_fill + | Ok _ -> Error "locate_policy must be reject_order or clip_fill" + | Error _ as error -> error + in + let* recall_policy = + match text "recall_policy" with + | Ok "reject_new_shorts" -> Ok Financing.Reject_new_shorts + | Ok "close_out" -> Ok Financing.Close_out + | Ok _ -> Error "recall_policy must be reject_new_shorts or close_out" + | Error _ as error -> error + in + Ok + (Financing.policy ~day_count ~compounding ~borrow_missing_data + ~cash_missing_data ~locate_policy ~recall_policy) + +let parse_settlement_calendar json = + let* fields = + object_fields ~name:"settlement calendar" + ~expected:[ "calendar_id"; "version"; "business_dates" ] + json + in + let* calendar_id = + Result.bind + (field fields "calendar_id") + (string ~name:"settlement calendar_id") + in + let* version = + Result.bind (field fields "version") + (string ~name:"settlement calendar version") + in + let* dates_json = + Result.bind + (field fields "business_dates") + (list ~name:"settlement business_dates") + in + let* business_dates = + map_list (string ~name:"settlement business date") dates_json + in + Settlement.calendar ~calendar_id ~version ~business_dates + +let parse_settlement_rule json = + let* fields = + object_fields ~name:"settlement rule" + ~expected:[ "instrument_id"; "calendar_id"; "lag_business_days" ] + json + in + let* instrument_id = + Result.bind + (field fields "instrument_id") + (parse_id Id.Instrument.of_string ~name:"settlement instrument_id") + in + let* calendar_id = + Result.bind + (field fields "calendar_id") + (string ~name:"settlement calendar_id") + in + let* lag_business_days = + Result.bind + (field fields "lag_business_days") + (integer ~name:"lag_business_days") + in + Settlement.rule ~instrument_id ~calendar_id ~lag_business_days + +let parse_settlement json = + let* fields = + object_fields ~name:"settlement policy" + ~expected: + [ "cash_buying_power"; "position_availability"; "calendars"; "rules" ] + json + in + let text name = Result.bind (field fields name) (string ~name) in + let* cash_buying_power = + match text "cash_buying_power" with + | Ok "total_cash" -> Ok Settlement.Total_cash + | Ok "settled_cash" -> Ok Settlement.Settled_cash + | Ok _ -> Error "cash_buying_power must be total_cash or settled_cash" + | Error _ as error -> error + in + let* position_availability = + match text "position_availability" with + | Ok "total_positions" -> Ok Settlement.Total_positions + | Ok "settled_positions" -> Ok Settlement.Settled_positions + | Ok _ -> + Error + "position_availability must be total_positions or settled_positions" + | Error _ as error -> error + in + let* calendars_json = + Result.bind (field fields "calendars") (list ~name:"settlement calendars") + in + let* calendars = map_list parse_settlement_calendar calendars_json in + let* rules_json = + Result.bind (field fields "rules") (list ~name:"settlement rules") + in + let* rules = map_list parse_settlement_rule rules_json in + Settlement.policy ~cash_buying_power ~position_availability ~calendars ~rules + +let parse_settlement_failure json = + let* fields = + object_fields ~name:"settlement failure" + ~expected:[ "instruction_id"; "reason" ] + json + in + let* instruction_id = + Result.bind + (field fields "instruction_id") + (string ~name:"settlement instruction_id") + in + let* reason = + Result.bind (field fields "reason") + (string ~name:"settlement failure reason") + in + Settlement.failure ~instruction_id ~reason + +let parse_borrow_observation json = + let* fields = + object_fields ~name:"borrow observation" + ~expected: + [ + "instrument_id"; + "effective_at"; + "available_quantity"; + "annual_rate_bps"; + "recalled"; + ] + json + in + let* instrument_id = + Result.bind + (field fields "instrument_id") + (parse_id Id.Instrument.of_string ~name:"borrow instrument_id") + in + let* effective_at = + Result.bind + (field fields "effective_at") + (parse_timestamp ~name:"borrow effective_at") + in + let* available_quantity = + Result.bind + (field fields "available_quantity") + (parse_quantity ~name:"borrow available_quantity") + in + let* annual_rate_bps = + Result.bind + (field fields "annual_rate_bps") + (integer ~name:"borrow annual_rate_bps") + in + let* recalled = + match field fields "recalled" with + | Ok (`Bool value) -> Ok value + | Ok _ -> Error "borrow recalled must be a boolean" + | Error _ as error -> error + in + Financing.borrow_observation ~instrument_id ~effective_at ~available_quantity + ~annual_rate_bps ~recalled + +let parse_cash_rate_observation json = + let* fields = + object_fields ~name:"cash rate observation" + ~expected: + [ "currency"; "effective_at"; "credit_rate_bps"; "debit_rate_bps" ] + json + in + let* currency = + Result.bind (field fields "currency") (string ~name:"cash rate currency") + in + let* effective_at = + Result.bind + (field fields "effective_at") + (parse_timestamp ~name:"cash rate effective_at") + in + let* credit_rate_bps = + Result.bind + (field fields "credit_rate_bps") + (integer ~name:"credit_rate_bps") + in + let* debit_rate_bps = + Result.bind (field fields "debit_rate_bps") (integer ~name:"debit_rate_bps") + in + Financing.cash_rate_observation ~currency ~effective_at ~credit_rate_bps + ~debit_rate_bps + +let parse_market_event json = + let* loose_fields = + match json with + | `Assoc fields -> Ok fields + | _ -> Error "market event must be a JSON object" + in + let* type_name = + Result.bind (field loose_fields "type") (string ~name:"market event type") + in + let common_fields = + [ + "type"; + "instrument_id"; + "event_at"; + "available_at"; + "received_at"; + "ingest_sequence"; + ] + in + let specific_fields = + match type_name with + | "quote" -> [ "bid_price"; "bid_quantity"; "ask_price"; "ask_quantity" ] + | "trade" -> [ "price"; "quantity"; "aggressor_side" ] + | _ -> [] + in + let* () = + if specific_fields = [] then + Error "market event type must be quote or trade" + else Ok () + in + let* fields = + object_fields + ~name:(type_name ^ " market event") + ~expected:(common_fields @ specific_fields) + json + in + let* instrument_id = + Result.bind + (field fields "instrument_id") + (parse_id Id.Instrument.of_string ~name:"market event instrument_id") + in + let* event_at = + Result.bind (field fields "event_at") + (parse_timestamp ~name:"market event_at") + in + let* available_at = + Result.bind + (field fields "available_at") + (parse_timestamp ~name:"market available_at") + in + let* received_at = + Result.bind + (field fields "received_at") + (parse_timestamp ~name:"market received_at") + in + let* ingest_sequence = + Result.bind + (field fields "ingest_sequence") + (parse_int64 ~name:"market ingest_sequence") + in + match type_name with + | "quote" -> + let* bid_price = + Result.bind (field fields "bid_price") (parse_price ~name:"bid_price") + in + let* bid_quantity = + Result.bind + (field fields "bid_quantity") + (parse_quantity ~name:"bid_quantity") + in + let* ask_price = + Result.bind (field fields "ask_price") (parse_price ~name:"ask_price") + in + let* ask_quantity = + Result.bind + (field fields "ask_quantity") + (parse_quantity ~name:"ask_quantity") + in + Market_event.quote ~instrument_id ~event_at ~available_at ~received_at + ~ingest_sequence ~bid_price ~bid_quantity ~ask_price ~ask_quantity + | "trade" -> + let* price = + Result.bind (field fields "price") (parse_price ~name:"trade price") + in + let* quantity = + Result.bind (field fields "quantity") + (parse_quantity ~name:"trade quantity") + in + let* aggressor_side = + Result.bind + (Result.bind + (field fields "aggressor_side") + (string ~name:"aggressor_side")) + Market_event.aggressor_side_of_string + in + Market_event.trade ~instrument_id ~event_at ~available_at ~received_at + ~ingest_sequence ~price ~quantity ~aggressor_side + | _ -> assert false + +let parse_order_book_level json = + let* fields = + object_fields ~name:"order-book level" ~expected:[ "price"; "quantity" ] + json + in + let* price = + Result.bind (field fields "price") (parse_price ~name:"book level price") + in + let* quantity = + Result.bind (field fields "quantity") + (parse_quantity ~name:"book level quantity") + in + Order_book_event.level ~price ~quantity + +let parse_order_book_event json = + let* loose_fields = + match json with + | `Assoc fields -> Ok fields + | _ -> Error "order-book event must be a JSON object" + in + let* type_name = + Result.bind + (field loose_fields "type") + (string ~name:"order-book event type") + in + let common = + [ + "type"; + "instrument_id"; + "event_at"; + "available_at"; + "received_at"; + "ingest_sequence"; + "book_sequence"; + ] + in + let specific = + match type_name with + | "snapshot" -> [ "bids"; "asks" ] + | "set" -> [ "side"; "price"; "quantity" ] + | "delete" -> [ "side"; "price" ] + | "trade" -> [ "price"; "quantity"; "aggressor_side" ] + | _ -> [] + in + let* () = + if specific = [] then + Error "order-book event type must be snapshot, set, delete, or trade" + else Ok () + in + let* fields = + object_fields + ~name:(type_name ^ " order-book event") + ~expected:(common @ specific) json + in + let* instrument_id = + Result.bind + (field fields "instrument_id") + (parse_id Id.Instrument.of_string ~name:"order-book instrument_id") + in + let* event_at = + Result.bind (field fields "event_at") + (parse_timestamp ~name:"order-book event_at") + in + let* available_at = + Result.bind + (field fields "available_at") + (parse_timestamp ~name:"order-book available_at") + in + let* received_at = + Result.bind + (field fields "received_at") + (parse_timestamp ~name:"order-book received_at") + in + let* ingest_sequence = + Result.bind + (field fields "ingest_sequence") + (parse_int64 ~name:"order-book ingest_sequence") + in + let* book_sequence = + Result.bind + (field fields "book_sequence") + (parse_int64 ~name:"order-book book_sequence") + in + let side () = + let* value = + Result.bind (field fields "side") (string ~name:"order-book side") + in + Order_book_event.side_of_string value + in + let price () = + Result.bind (field fields "price") (parse_price ~name:"order-book price") + in + let quantity () = + Result.bind (field fields "quantity") + (parse_quantity ~name:"order-book quantity") + in + match type_name with + | "snapshot" -> + let* bids_json = + Result.bind (field fields "bids") (list ~name:"order-book bids") + in + let* asks_json = + Result.bind (field fields "asks") (list ~name:"order-book asks") + in + let* bids = map_list parse_order_book_level bids_json in + let* asks = map_list parse_order_book_level asks_json in + Order_book_event.snapshot ~instrument_id ~event_at ~available_at + ~received_at ~ingest_sequence ~book_sequence ~bids ~asks + | "set" -> + let* side = side () in + let* price = price () in + let* quantity = quantity () in + Order_book_event.set ~instrument_id ~event_at ~available_at ~received_at + ~ingest_sequence ~book_sequence ~side ~price ~quantity + | "delete" -> + let* side = side () in + let* price = price () in + Order_book_event.delete ~instrument_id ~event_at ~available_at + ~received_at ~ingest_sequence ~book_sequence ~side ~price + | "trade" -> + let* price = price () in + let* quantity = quantity () in + let* aggressor_name = + Result.bind + (field fields "aggressor_side") + (string ~name:"order-book aggressor_side") + in + let* aggressor_side = + Market_event.aggressor_side_of_string aggressor_name + in + Order_book_event.trade ~instrument_id ~event_at ~available_at ~received_at + ~ingest_sequence ~book_sequence ~price ~quantity ~aggressor_side + | _ -> assert false + let parse_slice json = let* fields = object_fields ~name:"market slice" @@ -475,6 +1796,12 @@ let parse_slice json = "bars"; "fx_rates"; "corporate_actions"; + "borrow_observations"; + "cash_rate_observations"; + "settlement_failures"; + "lifecycle_events"; + "market_events"; + "order_book_events"; ] json in @@ -497,481 +1824,281 @@ let parse_slice 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 - Market_slice.create ~slice_sequence ~start_at ~end_at ~available_at - ~received_at ~bars ~fx_rates ~corporate_actions - -let changes_orders = function - | Strategy.Target_weights _ | Strategy.Target_quantities _ - | Strategy.Submit_order _ | Strategy.Cancel_order _ -> - true - | Strategy.Emit_metric _ -> false - -let validate_portfolio_target risk catalog = function - | Strategy.Target_weights targets -> - let ids = - List.map - (fun (target : Strategy.weight_target) -> target.instrument_id) - targets - in - let unique = List.sort_uniq Id.Instrument.compare ids in - if List.length unique <> List.length ids then - Error "target_weights must contain each instrument exactly once" - else if - not (Id.Instrument.Set.equal catalog (Id.Instrument.Set.of_list ids)) - then Error "target_weights must cover every configured instrument" - else - let gross = - List.fold_left - (fun result (target : Strategy.weight_target) -> - let* total = result in - let* absolute = Scalar.Weight.absolute target.Strategy.weight in - Scalar.Weight.add total absolute) - (Ok Scalar.Weight.zero) targets - in - let* gross = gross in - if - Int64.compare - (Scalar.Weight.to_micros gross) - (Scalar.Ratio.to_micros (Risk.max_leverage risk)) - > 0 - then Error "target gross weight exceeds maximum leverage" - else Ok () - | Strategy.Target_quantities targets -> - let ids = - List.map - (fun (target : Strategy.quantity_target) -> target.instrument_id) - targets - in - let unique = List.sort_uniq Id.Instrument.compare ids in - if List.length unique <> List.length ids then - Error "target_quantities must contain each instrument exactly once" - else if - not (Id.Instrument.Set.equal catalog (Id.Instrument.Set.of_list ids)) - then Error "target_quantities must cover every configured instrument" - else - List.fold_left - (fun result (target : Strategy.quantity_target) -> - let* () = result in - match Risk.instrument risk target.Strategy.instrument_id with - | None -> Error "target quantity refers to an unknown instrument" - | Some instrument -> - if - not - (Scalar.Quantity.is_multiple target.quantity - ~lot:instrument.Instrument.lot_size) - then - Error "target quantity is not aligned to its instrument lot" - else Risk.check_position risk target.quantity) - (Ok ()) targets - | Strategy.Submit_order request -> ( - if not (Id.Instrument.Set.mem request.Order.instrument_id catalog) then - Error "order refers to an unknown instrument" - else if - Scalar.Quantity.compare request.quantity (Risk.max_order_quantity risk) - > 0 - then Error "order exceeds the maximum order quantity" - else - match Risk.instrument risk request.instrument_id with - | None -> Error "order refers to an unknown instrument" - | Some instrument -> ( - if - not - (Scalar.Quantity.is_multiple request.quantity - ~lot:instrument.Instrument.lot_size) - then - Error "order quantity is not aligned to the instrument lot size" - else - match request.kind with - | Order.Market -> Ok () - | Order.Limit price -> - if Scalar.Price.is_multiple price ~tick:instrument.tick_size - then Ok () - else - Error - "limit price is not aligned to the instrument tick size")) - | Strategy.Cancel_order _ | Strategy.Emit_metric _ -> Ok () - -let validate_slices ~base_currency ~currencies ~instruments slices = - let catalog = - List.map (fun instrument -> instrument.Instrument.id) instruments - |> Id.Instrument.Set.of_list + let* borrow_json = + Result.bind + (field fields "borrow_observations") + (list ~name:"borrow_observations") in - let instrument_map = - List.fold_left - (fun map instrument -> - Id.Instrument.Map.add instrument.Instrument.id instrument map) - Id.Instrument.Map.empty instruments + let* borrow_observations = map_list parse_borrow_observation borrow_json in + let* cash_json = + Result.bind + (field fields "cash_rate_observations") + (list ~name:"cash_rate_observations") in - let expected_currencies = String_set.of_list currencies in - let one = Scalar.Price.of_decimal_string "1" |> Result.get_ok in - let rec validate previous_sequence previous_end previous_received action_ids = - function - | [] -> Ok () - | market_slice :: remaining -> - let ids = - List.map - (fun bar -> bar.Bar.instrument_id) - market_slice.Market_slice.bars - |> Id.Instrument.Set.of_list - in - let fx_currencies = - List.map - (fun mark -> mark.Market_slice.currency) - market_slice.Market_slice.fx_rates - |> String_set.of_list - in - let actions_valid = - List.for_all - (fun action -> - Id.Instrument.Set.mem action.Corporate_action.instrument_id - catalog) - market_slice.corporate_actions - in - let duplicate_action = - List.find_opt - (fun action -> - Id.Corporate_action.Set.mem action.Corporate_action.id action_ids) - market_slice.corporate_actions - in - let bars_aligned = - List.for_all - (fun bar -> - match - Id.Instrument.Map.find_opt bar.Bar.instrument_id instrument_map - with - | None -> false - | Some instrument -> - List.for_all - (fun price -> - Scalar.Price.is_multiple price ~tick:instrument.tick_size) - [ - bar.open_price; - bar.high_price; - bar.low_price; - bar.close_price; - ] - && Option.for_all - (fun volume -> - Scalar.Quantity.is_multiple volume - ~lot:instrument.lot_size) - bar.volume) - market_slice.bars - in - if not (Id.Instrument.Set.equal catalog ids) then - Error "each market slice must contain every configured instrument" - else if not (String_set.equal expected_currencies fx_currencies) then - Error "each market slice must contain every scenario currency FX rate" - else if - not - (Option.exists - (fun rate -> Scalar.Price.equal rate one) - (Market_slice.fx_rate market_slice base_currency)) - then Error "the base-currency FX rate must equal one" - else if not actions_valid then - Error "corporate action refers to an unknown instrument" - else if Option.is_some duplicate_action then - Error "corporate action IDs must be unique across the scenario" - else if not bars_aligned then - Error - "market prices and volumes must align with instrument increments" - else if - Option.exists - (fun sequence -> - Int64.compare market_slice.slice_sequence sequence <= 0) - previous_sequence - then Error "market slice sequence must increase" - else if - Option.exists - (fun end_at -> Ptime.compare market_slice.start_at end_at < 0) - previous_end - then Error "market slice start must not precede previous end" - else if - Option.exists - (fun received_at -> - Ptime.compare market_slice.received_at received_at < 0) - previous_received - then Error "market slice receipt time must not move backward" - else - let action_ids = - List.fold_left - (fun ids action -> - Id.Corporate_action.Set.add action.Corporate_action.id ids) - action_ids market_slice.corporate_actions - in - validate (Some market_slice.slice_sequence) (Some market_slice.end_at) - (Some market_slice.received_at) action_ids remaining + let* cash_rate_observations = + map_list parse_cash_rate_observation cash_json + in + let* failures_json = + Result.bind + (field fields "settlement_failures") + (list ~name:"settlement_failures") + in + let* settlement_failures = map_list parse_settlement_failure failures_json in + let* lifecycle_json = + Result.bind + (field fields "lifecycle_events") + (list ~name:"lifecycle_events") in - validate None None None Id.Corporate_action.Set.empty slices + let* lifecycle_events = map_list parse_lifecycle_event lifecycle_json in + let* events_json = + Result.bind (field fields "market_events") (list ~name:"market_events") + in + let* market_events = map_list parse_market_event events_json in + let* book_events_json = + Result.bind + (field fields "order_book_events") + (list ~name:"order_book_events") + in + let* order_book_events = map_list parse_order_book_event book_events_json in + Market_slice.create ~slice_sequence ~start_at ~end_at ~available_at + ~received_at ~bars ~fx_rates ~corporate_actions ~borrow_observations + ~cash_rate_observations ~settlement_failures ~lifecycle_events + ~market_events ~order_book_events -let validate_schedule risk catalog schedule slices = - let slice_sequences = - List.fold_left - (fun sequences market_slice -> - Int64_set.add market_slice.Market_slice.slice_sequence sequences) - Int64_set.empty slices - in - let slice_at sequence = - List.find_opt - (fun market_slice -> - Int64.equal market_slice.Market_slice.slice_sequence sequence) - slices - in - let next_slice sequence = - List.find_opt - (fun market_slice -> - Int64.compare market_slice.Market_slice.slice_sequence sequence > 0) - slices - in - let validate_item sequence intents = - if Int64.compare sequence 0L <= 0 then - Error "scheduled slice sequence must be positive" - else if not (Int64_set.mem sequence slice_sequences) then - Error - (Printf.sprintf - "scheduled intents refer to missing market slice sequence %Ld" - sequence) - else - let* () = - List.fold_left - (fun result intent -> - let* () = result in - validate_portfolio_target risk catalog intent) - (Ok ()) intents - in - match (slice_at sequence, next_slice sequence) with - | Some anchor, Some next - when List.exists changes_orders intents - && Ptime.compare anchor.received_at next.start_at > 0 -> - Error - (Printf.sprintf - "scheduled order intent after slice %Ld is received after the \ - next executable market slice starts" - sequence) - | _ -> Ok () - in - let rec validate previous = function - | [] -> Ok () - | (sequence, intents) :: remaining -> - if - Option.exists - (fun prior -> Int64.compare sequence prior <= 0) - previous - then Error "schedule sequences must increase" - else - let* () = validate_item sequence intents in - validate (Some sequence) remaining - in - validate None schedule +let child root field = root ^ "." ^ field -let of_yojson json = - let* fields = - object_fields ~name:"scenario" - ~expected: - [ - "contract_version"; - "metadata"; - "run_id"; - "base_currency"; - "initial_cash"; - "instruments"; - "risk"; - "execution"; - "max_internal_events"; - "schedule"; - "slices"; - ] - json - in - let* contract_json = field fields "contract_version" in - let* contract_version = string ~name:"contract_version" contract_json in - if not (String.equal contract_version Contract.version) then +let construct_header ~root ~contract_path ~contract_version + (shape : Scenario_shape.common) = + if not (Contract.is_supported contract_version) then Error - (Printf.sprintf "unsupported scenario contract_version %S (expected %S)" - contract_version Contract.version) + (Scenario_shape.error ~json_path:contract_path + (Printf.sprintf + "unsupported scenario contract_version %S (expected one of %s)" + contract_version + (String.concat ", " Contract.supported_versions))) else - let* metadata = field fields "metadata" in let* () = - match metadata with - | `Assoc _ -> validate_metadata metadata - | _ -> Error "metadata must be a JSON object" + match shape.metadata with + | `Assoc _ -> + validate_metadata shape.metadata |> at (child root "metadata") + | _ -> + Error + (Scenario_shape.error ~json_path:(child root "metadata") + "metadata must be a JSON object") in - let* run_json = field fields "run_id" in - let* run_id = parse_id Id.Run.of_string ~name:"run_id" run_json in - let* currency_json = field fields "base_currency" in - let* base_currency = string ~name:"base_currency" currency_json in - let* cash_json = field fields "initial_cash" in - let* cash_json = list ~name:"initial_cash" cash_json in - let* initial_cash = map_list parse_cash_balance cash_json in - let* () = - Account.create ~base_currency ~initial_cash |> Result.map (fun _ -> ()) + let metadata = shape.metadata in + let* run_id = + parse_id Id.Run.of_string ~name:"run_id" shape.run_id + |> at (child root "run_id") in - let* instruments_json = field fields "instruments" in - let* instruments_json = list ~name:"instruments" instruments_json in - let* instruments = map_list parse_instrument instruments_json in - if instruments = [] then - Error "scenario must define at least one instrument" - else - let currencies = - base_currency - :: List.map - (fun instrument -> instrument.Instrument.quote_currency) - instruments - |> List.sort_uniq String.compare - in - let cash_currencies = - List.map fst initial_cash |> List.sort_uniq String.compare - in - if cash_currencies <> currencies then - Error "initial_cash must contain every scenario currency exactly once" - else - let catalog = - List.map (fun instrument -> instrument.Instrument.id) instruments - |> Id.Instrument.Set.of_list - in - let* risk_json = field fields "risk" in - let* risk = parse_risk base_currency instruments risk_json in - let* execution_json = field fields "execution" in - let* execution_model, execution = parse_execution execution_json in - let* maximum_json = field fields "max_internal_events" in - let* max_internal_events = - integer ~name:"max_internal_events" maximum_json - in - if max_internal_events <= 0 then - Error "max_internal_events must be positive" - else - let* schedule_json = field fields "schedule" in - let* schedule_json = list ~name:"schedule" schedule_json in - let* schedule = map_list parse_schedule_item schedule_json in - let* slices_json = field fields "slices" in - let* slices_json = list ~name:"slices" slices_json in - let* slices = map_list parse_slice slices_json in - let* () = - validate_slices ~base_currency ~currencies ~instruments slices + let* base_currency = + string ~name:"base_currency" shape.base_currency + |> at (child root "base_currency") + in + let* initial_portfolio = + parse_initial_portfolio ~base_currency shape.initial_state + |> at (child root "initial_portfolio") + in + let initial_cash = initial_portfolio.Initial_portfolio.cash in + let* instruments_json = + list ~name:"instruments" shape.instruments + |> at (child root "instruments") + in + let* instruments = + map_list_at (child root "instruments") parse_instrument instruments_json + in + let* venue_calendars = + match shape.venue_calendars with + | None -> + Error "missing venue calendars" |> at (child root "venue_calendars") + | Some calendars_json -> + let* calendars_json = + list ~name:"venue_calendars" calendars_json + |> at (child root "venue_calendars") in - let* () = validate_schedule risk catalog schedule slices in - Ok - { - contract_version; - metadata; - run_id; - base_currency; - initial_cash; - instruments; - risk; - execution_model; - execution; - max_internal_events; - schedule; - slices; - } + map_list_at + (child root "venue_calendars") + parse_venue_calendar calendars_json + in + let* max_internal_events = + integer ~name:"max_internal_events" shape.max_internal_events + |> at (child root "max_internal_events") + in + let* currencies, catalog = + Scenario_validation.header ~root ~base_currency ~initial_cash ~instruments + ~venue_calendars ~max_internal_events + in + let* risk = + parse_risk base_currency instruments shape.risk |> at (child root "risk") + in + let* () = + Scenario_validation.initial_portfolio ~root ~currencies ~catalog + ~instruments ~risk initial_portfolio + in + let* execution_model, execution = + parse_execution ~contract_version ~instruments shape.execution + |> at (child root "execution") + in + let* financing = + match shape.financing with + | Some json -> parse_financing json |> at (child root "financing") + | None -> Error "missing financing policy" |> at (child root "financing") + in + let* settlement = + match shape.settlement with + | Some json -> parse_settlement json |> at (child root "settlement") + | None -> + Error "missing settlement policy" |> at (child root "settlement") + in + let header : stream_header = + { + contract_version; + metadata; + run_id; + base_currency; + initial_portfolio; + instruments; + venue_calendars; + risk; + execution_model; + execution; + financing; + settlement; + max_internal_events; + } + in + Ok (header, currencies, catalog) + +let construct_batch (shape : Scenario_shape.batch) = + let root = "$" in + let contract_path = "$.contract_version" in + let* contract_version = + string ~name:"contract_version" shape.contract_version |> at contract_path + in + let* header, currencies, catalog = + construct_header ~root ~contract_path ~contract_version shape.common + in + let* schedule_json = + list ~name:"schedule" shape.schedule |> at "$.schedule" + in + let* schedule = map_list_at "$.schedule" parse_schedule_item schedule_json in + let* slices_json = list ~name:"slices" shape.slices |> at "$.slices" in + let* slices = map_list_at "$.slices" parse_slice slices_json in + let* () = + Scenario_validation.batch ~root ~base_currency:header.base_currency + ~currencies ~instruments:header.instruments ~risk:header.risk ~catalog + ~schedule ~slices + in + Ok + { + contract_version = header.contract_version; + metadata = header.metadata; + run_id = header.run_id; + base_currency = header.base_currency; + initial_portfolio = header.initial_portfolio; + instruments = header.instruments; + venue_calendars = header.venue_calendars; + risk = header.risk; + execution_model = header.execution_model; + execution = header.execution; + financing = header.financing; + settlement = header.settlement; + max_internal_events = header.max_internal_events; + schedule; + slices; + } + +let diagnostic code (error : Scenario_shape.error) = + Diagnostic.make ~code ~phase:Diagnostic.Validation ~json_path:error.json_path + error.message + +let of_yojson json = + let supplied_version = + match json with + | `Assoc fields -> List.assoc_opt "contract_version" fields + | _ -> None + in + let* () = + match supplied_version with + | Some (`String supplied) when not (Contract.is_supported supplied) -> + Error + (Diagnostic.make ~code:Diagnostic.Scenario_unsupported_contract + ~phase:Diagnostic.Validation ~json_path:"$.contract_version" + (Printf.sprintf + "unsupported scenario contract_version %S (expected one of %s)" + supplied + (String.concat ", " Contract.supported_versions))) + | _ -> Ok () + in + let code = Diagnostic.Scenario_invalid in + let* () = check_batch_limits json in + let* shape = + Scenario_shape.batch json |> Result.map_error (diagnostic code) + in + construct_batch shape |> Result.map_error (diagnostic code) let of_string document = try Yojson.Safe.from_string document |> of_yojson - with Yojson.Json_error message -> Error ("invalid scenario JSON: " ^ message) + with Yojson.Json_error message as exception_ -> + Error + (Diagnostic.of_exception ~code:Diagnostic.Scenario_invalid_json + ~phase:Diagnostic.Input ~json_path:"$" + ~message:("invalid scenario JSON: " ^ message) + exception_) let read_file path = try In_channel.with_open_bin path In_channel.input_all |> of_string - with Sys_error message -> Error ("could not read scenario: " ^ message) + with Sys_error message as exception_ -> + Error + (Diagnostic.of_exception ~code:Diagnostic.Input_io ~phase:Diagnostic.Input + ~message:("could not read scenario: " ^ message) + exception_) let stream_header_of_yojson ~contract_version json = - let* fields = - object_fields ~name:"scenario stream header payload" - ~expected: - [ - "metadata"; - "run_id"; - "base_currency"; - "initial_cash"; - "instruments"; - "risk"; - "execution"; - "max_internal_events"; - ] - json + let code = + if Contract.is_supported contract_version then + Diagnostic.Scenario_stream_invalid + else Diagnostic.Scenario_unsupported_contract in - let scenario_json = - `Assoc - ((("contract_version", `String contract_version) :: fields) - @ [ ("schedule", `List []); ("slices", `List []) ]) + let* () = check_stream_header_limits json in + let* shape = + Scenario_shape.stream_header json |> Result.map_error (diagnostic code) in - let* scenario = of_yojson scenario_json in - Ok - { - contract_version = scenario.contract_version; - metadata = scenario.metadata; - run_id = scenario.run_id; - base_currency = scenario.base_currency; - initial_cash = scenario.initial_cash; - instruments = scenario.instruments; - risk = scenario.risk; - execution_model = scenario.execution_model; - execution = scenario.execution; - max_internal_events = scenario.max_internal_events; - } + construct_header ~root:"$.payload" ~contract_path:"$.contract_version" + ~contract_version shape + |> Result.map (fun (header, _, _) -> header) + |> Result.map_error (diagnostic code) let stream_item_of_yojson header ~previous json = - let* fields = - object_fields ~name:"scenario stream slice payload" - ~expected:[ "market_slice"; "intents" ] - json + let* () = check_stream_item_limits json in + let code = Diagnostic.Scenario_stream_invalid in + let* shape = + Scenario_shape.stream_item json |> Result.map_error (diagnostic code) in - let* slice_json = field fields "market_slice" in - let* market_slice = parse_slice slice_json in - let* intents_json = field fields "intents" in - let* intents_json = list ~name:"intents" intents_json in - let* intents = map_list parse_intent intents_json in - let catalog = - List.map (fun instrument -> instrument.Instrument.id) header.instruments - |> Id.Instrument.Set.of_list + let* market_slice = + parse_slice shape.market_slice + |> at "$.payload.market_slice" + |> Result.map_error (diagnostic code) in - let slices = - match previous with - | None -> [ market_slice ] - | Some item -> [ item.market_slice; market_slice ] + let* intents_json = + list ~name:"intents" shape.intents + |> at "$.payload.intents" + |> Result.map_error (diagnostic code) in - let prior_action_ids = + let* intents = + map_list_at "$.payload.intents" parse_intent intents_json + |> Result.map_error (diagnostic code) + in + let previous_slice, previous_intents, prior_action_ids = match previous with - | None -> Id.Corporate_action.Set.empty - | Some item -> item.action_ids + | None -> (None, [], Id.Corporate_action.Set.empty) + | Some item -> (Some item.market_slice, item.intents, item.action_ids) in let* action_ids = - List.fold_left - (fun result action -> - let* ids = result in - if Id.Corporate_action.Set.mem action.Corporate_action.id ids then - Error "corporate action IDs must be unique across the scenario stream" - else Ok (Id.Corporate_action.Set.add action.id ids)) - (Ok prior_action_ids) market_slice.corporate_actions - in - let currencies = - header.base_currency - :: List.map - (fun instrument -> instrument.Instrument.quote_currency) - header.instruments - |> List.sort_uniq String.compare - in - let* () = - validate_slices ~base_currency:header.base_currency ~currencies - ~instruments:header.instruments slices - in - let* () = - List.fold_left - (fun result intent -> - let* () = result in - validate_portfolio_target header.risk catalog intent) - (Ok ()) intents - in - let* () = - match previous with - | Some item - when List.exists changes_orders item.intents - && Ptime.compare item.market_slice.received_at market_slice.start_at - > 0 -> - Error - (Printf.sprintf - "scheduled order intent after slice %Ld is received after the \ - next executable market slice starts" - item.market_slice.slice_sequence) - | None | Some _ -> Ok () + Scenario_validation.stream_item ~root:"$.payload" + ~base_currency:header.base_currency ~instruments:header.instruments + ~risk:header.risk ~previous_slice ~previous_intents ~prior_action_ids + ~market_slice ~intents + |> Result.map_error (diagnostic code) in Ok { market_slice; intents; action_ids } diff --git a/lib/scenario.mli b/lib/scenario.mli index de777df..29ff2af 100644 --- a/lib/scenario.mli +++ b/lib/scenario.mli @@ -5,11 +5,14 @@ type t = private { metadata : Yojson.Safe.t; run_id : Id.Run.t; base_currency : string; - initial_cash : (string * Scalar.Money.t) list; + initial_portfolio : Initial_portfolio.t; instruments : Instrument.t list; + venue_calendars : Venue_calendar.t list; risk : Risk.t; execution_model : Execution_model.t; execution : Execution.t; + financing : Financing.policy; + settlement : Settlement.policy; max_internal_events : int; schedule : (int64 * Strategy.intent list) list; slices : Market_slice.t list; @@ -20,11 +23,14 @@ type stream_header = private { metadata : Yojson.Safe.t; run_id : Id.Run.t; base_currency : string; - initial_cash : (string * Scalar.Money.t) list; + initial_portfolio : Initial_portfolio.t; instruments : Instrument.t list; + venue_calendars : Venue_calendar.t list; risk : Risk.t; execution_model : Execution_model.t; execution : Execution.t; + financing : Financing.policy; + settlement : Settlement.policy; max_internal_events : int; } @@ -34,16 +40,18 @@ type stream_item = private { action_ids : Id.Corporate_action.Set.t; } -val of_yojson : Yojson.Safe.t -> (t, string) result -val of_string : string -> (t, string) result -val read_file : string -> (t, string) result -val intent_of_yojson : Yojson.Safe.t -> (Strategy.intent, string) result +val of_yojson : Yojson.Safe.t -> (t, Diagnostic.t) result +val of_string : string -> (t, Diagnostic.t) result +val read_file : string -> (t, Diagnostic.t) result +val intent_of_yojson : Yojson.Safe.t -> (Strategy.intent, Diagnostic.t) result val stream_header_of_yojson : - contract_version:string -> Yojson.Safe.t -> (stream_header, string) result + contract_version:string -> + Yojson.Safe.t -> + (stream_header, Diagnostic.t) result val stream_item_of_yojson : stream_header -> previous:stream_item option -> Yojson.Safe.t -> - (stream_item, string) result + (stream_item, Diagnostic.t) result diff --git a/lib/scenario_shape.ml b/lib/scenario_shape.ml new file mode 100644 index 0000000..40bed59 --- /dev/null +++ b/lib/scenario_shape.ml @@ -0,0 +1,161 @@ +type error = { json_path : string; message : string } + +type common = { + metadata : Yojson.Safe.t; + run_id : Yojson.Safe.t; + base_currency : Yojson.Safe.t; + initial_state : Yojson.Safe.t; + instruments : Yojson.Safe.t; + venue_calendars : Yojson.Safe.t option; + risk : Yojson.Safe.t; + execution : Yojson.Safe.t; + financing : Yojson.Safe.t option; + settlement : Yojson.Safe.t option; + max_internal_events : Yojson.Safe.t; +} + +type batch = { + contract_version : Yojson.Safe.t; + common : common; + schedule : Yojson.Safe.t; + slices : Yojson.Safe.t; +} + +type stream_item = { market_slice : Yojson.Safe.t; intents : Yojson.Safe.t } + +let error ~json_path message = { json_path; message } + +let ( let* ) result function_ = + match result with Ok value -> function_ value | Error _ as error -> error + +let object_fields ~json_path ~name ~expected = function + | `Assoc fields -> + let names = List.map fst fields in + let actual = List.sort_uniq String.compare names in + let expected = List.sort_uniq String.compare expected in + if List.length names <> List.length actual then + let duplicates = + List.filter + (fun key -> List.length (List.filter (String.equal key) names) > 1) + actual + in + Error + (error ~json_path + (Printf.sprintf "%s has duplicate JSON fields: [%s]" name + (String.concat "," duplicates))) + else if actual = expected then Ok fields + else + let missing = + List.filter (fun key -> not (List.mem key actual)) expected + in + let extra = + List.filter (fun key -> not (List.mem key expected)) actual + in + Error + (error ~json_path + (Printf.sprintf "%s fields differ: missing=[%s], extra=[%s]" name + (String.concat "," missing) + (String.concat "," extra))) + | _ -> Error (error ~json_path (name ^ " must be a JSON object")) + +let field ~root fields name = + match List.assoc_opt name fields with + | Some value -> Ok value + | None -> + Error + (error ~json_path:(root ^ "." ^ name) ("missing JSON field: " ^ name)) + +let common ~root fields = + let* metadata = field ~root fields "metadata" in + let* run_id = field ~root fields "run_id" in + let* base_currency = field ~root fields "base_currency" in + let* initial_state = field ~root fields "initial_portfolio" in + let* instruments = field ~root fields "instruments" in + let venue_calendars = List.assoc_opt "venue_calendars" fields in + let* risk = field ~root fields "risk" in + let* execution = field ~root fields "execution" in + let financing = List.assoc_opt "financing" fields in + let settlement = List.assoc_opt "settlement" fields in + let* max_internal_events = field ~root fields "max_internal_events" in + Ok + { + metadata; + run_id; + base_currency; + initial_state; + instruments; + venue_calendars; + risk; + execution; + financing; + settlement; + max_internal_events; + } + +let batch json = + let root = "$" in + let* preliminary = + match json with + | `Assoc fields -> field ~root fields "contract_version" + | _ -> Error (error ~json_path:root "scenario must be a JSON object") + in + let _ = preliminary in + let* fields = + object_fields ~json_path:root ~name:"scenario" + ~expected: + [ + "contract_version"; + "metadata"; + "run_id"; + "base_currency"; + "initial_portfolio"; + "instruments"; + "venue_calendars"; + "risk"; + "execution"; + "financing"; + "settlement"; + "max_internal_events"; + "schedule"; + "slices"; + ] + json + in + let* contract_version_json = field ~root fields "contract_version" in + let* common = common ~root fields in + let* schedule = field ~root fields "schedule" in + let* slices = field ~root fields "slices" in + Ok { contract_version = contract_version_json; common; schedule; slices } + +let stream_header json = + let root = "$.payload" in + let* fields = + object_fields ~json_path:root ~name:"scenario stream header payload" + ~expected: + [ + "metadata"; + "run_id"; + "base_currency"; + "initial_portfolio"; + "instruments"; + "venue_calendars"; + "risk"; + "execution"; + "financing"; + "settlement"; + "max_internal_events"; + ] + json + in + common ~root fields + +let stream_item json = + let root = "$.payload" in + let* fields = + object_fields ~json_path:root ~name:"scenario stream slice payload" + ~expected:[ "market_slice"; "intents" ] + json + in + let* market_slice = field ~root fields "market_slice" in + let* intents = field ~root fields "intents" in + Ok { market_slice; intents } diff --git a/lib/scenario_shape.mli b/lib/scenario_shape.mli new file mode 100644 index 0000000..8b32093 --- /dev/null +++ b/lib/scenario_shape.mli @@ -0,0 +1,31 @@ +(** Strict top-level JSON shapes for batch and streamed scenarios. *) + +type error = { json_path : string; message : string } + +type common = { + metadata : Yojson.Safe.t; + run_id : Yojson.Safe.t; + base_currency : Yojson.Safe.t; + initial_state : Yojson.Safe.t; + instruments : Yojson.Safe.t; + venue_calendars : Yojson.Safe.t option; + risk : Yojson.Safe.t; + execution : Yojson.Safe.t; + financing : Yojson.Safe.t option; + settlement : Yojson.Safe.t option; + max_internal_events : Yojson.Safe.t; +} + +type batch = { + contract_version : Yojson.Safe.t; + common : common; + schedule : Yojson.Safe.t; + slices : Yojson.Safe.t; +} + +type stream_item = { market_slice : Yojson.Safe.t; intents : Yojson.Safe.t } + +val error : json_path:string -> string -> error +val batch : Yojson.Safe.t -> (batch, error) result +val stream_header : Yojson.Safe.t -> (common, error) result +val stream_item : Yojson.Safe.t -> (stream_item, error) result diff --git a/lib/scenario_stream.ml b/lib/scenario_stream.ml index 2870634..d11c97c 100644 --- a/lib/scenario_stream.ml +++ b/lib/scenario_stream.ml @@ -1,13 +1,18 @@ let ( let* ) result function_ = match result with Ok value -> function_ value | Error _ as error -> error +let invalid ?json_path ?line ?sequence message = + Diagnostic.make ?json_path ?line ?sequence + ~code:Diagnostic.Scenario_stream_invalid ~phase:Diagnostic.Validation + message + let object_fields ~name ~expected = function | `Assoc fields -> let names = List.map fst fields in let actual = List.sort_uniq String.compare names in let expected = List.sort_uniq String.compare expected in if List.length names <> List.length actual then - Error (name ^ " has duplicate JSON fields") + Error (invalid (name ^ " has duplicate JSON fields")) else if actual = expected then Ok fields else let missing = @@ -17,19 +22,20 @@ let object_fields ~name ~expected = function List.filter (fun key -> not (List.mem key expected)) actual in Error - (Printf.sprintf "%s fields differ: missing=[%s], extra=[%s]" name - (String.concat "," missing) - (String.concat "," extra)) - | _ -> Error (name ^ " must be a JSON object") + (invalid + (Printf.sprintf "%s fields differ: missing=[%s], extra=[%s]" name + (String.concat "," missing) + (String.concat "," extra))) + | _ -> Error (invalid (name ^ " must be a JSON object")) let field fields name = match List.assoc_opt name fields with | Some value -> Ok value - | None -> Error ("missing JSON field: " ^ name) + | None -> Error (invalid ("missing JSON field: " ^ name)) let string ~name = function | `String value -> Ok value - | _ -> Error (name ^ " must be a string") + | _ -> Error (invalid (name ^ " must be a string")) let int64_string ~name ~positive value = let* value = string ~name value in @@ -41,51 +47,78 @@ let int64_string ~name ~positive value = Ok parsed | _ -> let requirement = if positive then "a positive" else "a nonnegative" in - Error (name ^ " must be " ^ requirement ^ " canonical int64 string") + Error + (invalid (name ^ " must be " ^ requirement ^ " canonical int64 string")) let parse_json ~line_number line = if String.equal line "" then - Error "scenario stream must not contain blank records" + Error + (invalid ~line:line_number ~json_path:"$" + "scenario stream must not contain blank records") else try Ok (Yojson.Safe.from_string line) - with Yojson.Json_error message -> + with Yojson.Json_error message as exception_ -> Error - (Printf.sprintf "invalid scenario stream JSON at line %d: %s" - line_number message) + (Diagnostic.of_exception ~code:Diagnostic.Scenario_invalid_json + ~phase:Diagnostic.Input ~line:line_number ~json_path:"$" + ~message: + (Printf.sprintf "invalid scenario stream JSON at line %d: %s" + line_number message) + exception_) -type envelope = { record_type : string; payload : Yojson.Safe.t } +type envelope = { + contract_version : string; + record_type : string; + payload : Yojson.Safe.t; +} let parse_envelope ~line_number ~expected_sequence line = - let* json = parse_json ~line_number line in - let name = Printf.sprintf "scenario stream record %d" line_number in - let* fields = - object_fields ~name - ~expected: - [ "contract_version"; "scenario_sequence"; "record_type"; "payload" ] - json - in - let* contract_json = field fields "contract_version" in - let* contract_version = string ~name:"contract_version" contract_json in - if not (String.equal contract_version Contract.version) then - Error - (Printf.sprintf "unsupported scenario contract_version %S (expected %S)" - contract_version Contract.version) - else - let* sequence_json = field fields "scenario_sequence" in - let* scenario_sequence = - int64_string ~name:"scenario_sequence" ~positive:true sequence_json + let result = + let* json = parse_json ~line_number line in + let name = Printf.sprintf "scenario stream record %d" line_number in + let* fields = + object_fields ~name + ~expected: + [ "contract_version"; "scenario_sequence"; "record_type"; "payload" ] + json in - if not (Int64.equal scenario_sequence expected_sequence) then - Error "scenario_sequence must be contiguous and start at one" + let* contract_json = field fields "contract_version" in + let* contract_version = string ~name:"contract_version" contract_json in + if not (Contract.is_supported contract_version) then + Error + (Diagnostic.make ~code:Diagnostic.Scenario_unsupported_contract + ~phase:Diagnostic.Validation ~line:line_number + ~sequence:expected_sequence ~json_path:"$.contract_version" + (Printf.sprintf + "unsupported scenario contract_version %S (expected one of %s)" + contract_version + (String.concat ", " Contract.supported_versions))) else - let* type_json = field fields "record_type" in - let* record_type = string ~name:"record_type" type_json in - let* payload = field fields "payload" in - Ok { record_type; payload } + let* sequence_json = field fields "scenario_sequence" in + let* scenario_sequence = + int64_string ~name:"scenario_sequence" ~positive:true sequence_json + in + if not (Int64.equal scenario_sequence expected_sequence) then + Error + (invalid ~line:line_number ~sequence:scenario_sequence + ~json_path:"$.scenario_sequence" + "scenario_sequence must be contiguous and start at one") + else + let* type_json = field fields "record_type" in + let* record_type = string ~name:"record_type" type_json in + let* payload = field fields "payload" in + Ok { contract_version; record_type; payload } + in + Result.map_error + (Diagnostic.annotate ~line:line_number ~sequence:expected_sequence + ~json_path:"$") + result let successor sequence = if Int64.equal sequence Int64.max_int then - Error "scenario_sequence is exhausted" + Error + (invalid ~sequence ~json_path:"$.scenario_sequence" + "scenario_sequence is exhausted") else Ok (Int64.succ sequence) let footer_count payload = @@ -96,66 +129,164 @@ let footer_count payload = let* count_json = field fields "slice_count" in int64_string ~name:"slice_count" ~positive:false count_json -let fold_channel channel ~init ~step ~finish = +type bounded_line = End | Line of string | Too_large of int + +let read_bounded_line channel buffer = + let maximum = Bytes.length buffer in + let rec drain observed = + match input_char channel with + | '\n' -> Too_large observed + | _ when observed = Int.max_int -> Too_large observed + | _ -> drain (observed + 1) + | exception End_of_file -> Too_large observed + in + let rec read length = + match input_char channel with + | '\n' -> Line (Bytes.sub_string buffer 0 length) + | character when length < maximum -> + Bytes.set buffer length character; + read (length + 1) + | _ -> drain (maximum + 1) + | exception End_of_file -> + if length = 0 then End else Line (Bytes.sub_string buffer 0 length) + in + read 0 + +let resource_limit ~sequence ~line ~observed ~allowed = + Diagnostic.make ?sequence ~line ~code:Diagnostic.Resource_limit + ~phase:Diagnostic.Input + (Printf.sprintf "scenario stream record is %d bytes; limit is %d bytes" + observed allowed) + +let read_record ?sequence ~line channel buffer = + match read_bounded_line channel buffer with + | End -> Ok None + | Line value -> Ok (Some value) + | Too_large observed -> + Error + (resource_limit ~sequence ~line ~observed ~allowed:(Bytes.length buffer)) + +let fold_channel ?(max_record_bytes = Resource_limits.scenario_record_bytes) + channel ~init ~step ~finish = let line_number = ref 1 in - match In_channel.input_line channel with - | None -> Error "scenario stream must start with scenario_header" - | Some line -> - let* envelope = - parse_envelope ~line_number:1 ~expected_sequence:1L line - in - if not (String.equal envelope.record_type "scenario_header") then - Error "scenario_header must be the first scenario stream record" - else - let* header = - Scenario.stream_header_of_yojson ~contract_version:Contract.version - envelope.payload + if max_record_bytes <= 0 then + Error + (Diagnostic.make ~code:Diagnostic.Resource_limit + ~phase:Diagnostic.Validation + "scenario record byte limit must be positive") + else if max_record_bytes > Resource_limits.scenario_record_bytes then + Error + (Diagnostic.make ~code:Diagnostic.Resource_limit + ~phase:Diagnostic.Validation + (Printf.sprintf + "scenario record byte limit is %d bytes; maximum is %d bytes" + max_record_bytes Resource_limits.scenario_record_bytes)) + else + let buffer = Bytes.create max_record_bytes in + match read_record ~sequence:1L ~line:1 channel buffer with + | Error _ as error -> error + | Ok None -> + Error + (invalid ~line:1 ~sequence:1L + "scenario stream must start with scenario_header") + | Ok (Some line) -> + let* envelope = + parse_envelope ~line_number:1 ~expected_sequence:1L line in - let* state = init header in - let rec loop state previous slice_count expected_sequence = - incr line_number; - match In_channel.input_line channel with - | None -> Error "scenario_end must terminate the scenario stream" - | Some line -> - let* envelope = - parse_envelope ~line_number:!line_number ~expected_sequence line - in - if String.equal envelope.record_type "market_slice" then - let* item = - Scenario.stream_item_of_yojson header ~previous - envelope.payload + if not (String.equal envelope.record_type "scenario_header") then + Error + (invalid ~line:1 ~sequence:1L ~json_path:"$.record_type" + "scenario_header must be the first scenario stream record") + else + let* header = + Scenario.stream_header_of_yojson + ~contract_version:envelope.contract_version envelope.payload + |> Result.map_error + (Diagnostic.annotate ~line:1 ~sequence:1L + ~json_path:"$.payload") + in + let* state = init header in + let rec loop state previous slice_count expected_sequence = + incr line_number; + match + read_record ~sequence:expected_sequence ~line:!line_number channel + buffer + with + | Error _ as error -> error + | Ok None -> + Error + (invalid ~line:!line_number ~sequence:expected_sequence + "scenario_end must terminate the scenario stream") + | Ok (Some line) -> + let* envelope = + parse_envelope ~line_number:!line_number ~expected_sequence + line in - let* state = step state item in - let* expected_sequence = successor expected_sequence in - if Int64.equal slice_count Int64.max_int then - Error "scenario slice count is exhausted" - else - loop state (Some item) (Int64.succ slice_count) - expected_sequence - else if String.equal envelope.record_type "scenario_end" then - let* declared_count = footer_count envelope.payload in - if not (Int64.equal declared_count slice_count) then + if + not + (String.equal envelope.contract_version + header.contract_version) + then Error - "scenario_end slice_count differs from streamed market \ - slices" + (invalid ~line:!line_number ~sequence:expected_sequence + ~json_path:"$.contract_version" + "scenario stream contract_version must remain constant") + else if String.equal envelope.record_type "market_slice" then + let* item = + Scenario.stream_item_of_yojson header ~previous + envelope.payload + |> Result.map_error + (Diagnostic.annotate ~line:!line_number + ~sequence:expected_sequence ~json_path:"$.payload") + in + let* state = step state item in + let* expected_sequence = successor expected_sequence in + if Int64.equal slice_count Int64.max_int then + Error + (invalid ~line:!line_number ~sequence:expected_sequence + "scenario slice count is exhausted") + else + loop state (Some item) (Int64.succ slice_count) + expected_sequence + else if String.equal envelope.record_type "scenario_end" then + let* declared_count = + footer_count envelope.payload + |> Result.map_error + (Diagnostic.annotate ~line:!line_number + ~sequence:expected_sequence ~json_path:"$.payload") + in + if not (Int64.equal declared_count slice_count) then + Error + (invalid ~line:!line_number ~sequence:expected_sequence + ~json_path:"$.payload.slice_count" + "scenario_end slice_count differs from streamed \ + market slices") + else + let terminal_line = !line_number + 1 in + match read_record ~line:terminal_line channel buffer with + | Error _ as error -> error + | Ok (Some _) -> + Error + (invalid ~line:terminal_line + "scenario_end must be the terminal scenario \ + stream record") + | Ok None -> finish state ~slice_count else - match In_channel.input_line channel with - | Some _ -> - Error - "scenario_end must be the terminal scenario stream \ - record" - | None -> finish state ~slice_count - else - Error - ("unsupported scenario stream record_type: " - ^ envelope.record_type) - in - let* expected_sequence = successor 1L in - loop state None 0L expected_sequence + Error + (invalid ~line:!line_number ~sequence:expected_sequence + ~json_path:"$.record_type" + ("unsupported scenario stream record_type: " + ^ envelope.record_type)) + in + let* expected_sequence = successor 1L in + loop state None 0L expected_sequence -let fold_file path ~init ~step ~finish = +let fold_file ?max_record_bytes path ~init ~step ~finish = try In_channel.with_open_bin path (fun channel -> - fold_channel channel ~init ~step ~finish) - with Sys_error message -> - Error ("could not read scenario stream: " ^ message) + fold_channel ?max_record_bytes channel ~init ~step ~finish) + with Sys_error message as exception_ -> + Error + (Diagnostic.of_exception ~code:Diagnostic.Input_io ~phase:Diagnostic.Input + ~message:("could not read scenario stream: " ^ message) + exception_) diff --git a/lib/scenario_stream.mli b/lib/scenario_stream.mli index edd4c8d..eeed704 100644 --- a/lib/scenario_stream.mli +++ b/lib/scenario_stream.mli @@ -1,15 +1,17 @@ (** Bounded-memory reader for versioned JSON Lines replay scenarios. *) val fold_channel : + ?max_record_bytes:int -> in_channel -> - init:(Scenario.stream_header -> ('state, string) result) -> - step:('state -> Scenario.stream_item -> ('state, string) result) -> - finish:('state -> slice_count:int64 -> ('result, string) result) -> - ('result, string) result + init:(Scenario.stream_header -> ('state, Diagnostic.t) result) -> + step:('state -> Scenario.stream_item -> ('state, Diagnostic.t) result) -> + finish:('state -> slice_count:int64 -> ('result, Diagnostic.t) result) -> + ('result, Diagnostic.t) result val fold_file : + ?max_record_bytes:int -> string -> - init:(Scenario.stream_header -> ('state, string) result) -> - step:('state -> Scenario.stream_item -> ('state, string) result) -> - finish:('state -> slice_count:int64 -> ('result, string) result) -> - ('result, string) result + init:(Scenario.stream_header -> ('state, Diagnostic.t) result) -> + step:('state -> Scenario.stream_item -> ('state, Diagnostic.t) result) -> + finish:('state -> slice_count:int64 -> ('result, Diagnostic.t) result) -> + ('result, Diagnostic.t) result diff --git a/lib/scenario_validation.ml b/lib/scenario_validation.ml new file mode 100644 index 0000000..fa38b4d --- /dev/null +++ b/lib/scenario_validation.ml @@ -0,0 +1,619 @@ +module Int64_map = Map.Make (Int64) +module String_set = Set.Make (String) + +let ( let* ) result function_ = + match result with Ok value -> function_ value | Error _ as error -> error + +let fail ~json_path message = Error (Scenario_shape.error ~json_path message) + +let at json_path result = + Result.map_error + (fun message -> Scenario_shape.error ~json_path message) + result + +let child root field = root ^ "." ^ field + +let validate_venue_calendars ~root catalog venue_calendars = + let ids = + List.map (fun calendar -> calendar.Venue_calendar.id) venue_calendars + in + let unique_ids = List.sort_uniq Id.Venue_calendar.compare ids in + if List.length ids <> List.length unique_ids then + fail + ~json_path:(child root "venue_calendars") + "venue calendar IDs must be unique" + else + let coverage, overlap = + List.fold_left + (fun (covered, overlap) calendar -> + let members = calendar.Venue_calendar.instrument_ids in + ( Id.Instrument.Set.union covered members, + overlap + || not + (Id.Instrument.Set.is_empty + (Id.Instrument.Set.inter covered members)) )) + (Id.Instrument.Set.empty, false) + venue_calendars + in + if overlap then + fail + ~json_path:(child root "venue_calendars") + "each instrument must reference exactly one venue calendar" + else if not (Id.Instrument.Set.equal coverage catalog) then + fail + ~json_path:(child root "venue_calendars") + "venue calendars must cover every configured instrument exactly once" + else Ok () + +let header ~root ~base_currency ~initial_cash ~instruments ~venue_calendars + ~max_internal_events = + if instruments = [] then + fail ~json_path:(child root "instruments") + "scenario must define at least one instrument" + else + let catalog = + List.map (fun instrument -> instrument.Instrument.id) instruments + |> Id.Instrument.Set.of_list + in + if Id.Instrument.Set.cardinal catalog <> List.length instruments then + fail ~json_path:(child root "instruments") "instrument IDs must be unique" + else + let* () = validate_venue_calendars ~root catalog venue_calendars in + let currencies = + base_currency + :: List.map + (fun instrument -> instrument.Instrument.quote_currency) + instruments + |> List.sort_uniq String.compare + in + let cash_currencies = + List.map fst initial_cash |> List.sort_uniq String.compare + in + if cash_currencies <> currencies then + fail + ~json_path:(child root "initial_portfolio.cash") + "initial cash must contain every scenario currency exactly once" + else if max_internal_events <= 0 then + fail + ~json_path:(child root "max_internal_events") + "max_internal_events must be positive" + else if max_internal_events > Resource_limits.internal_events then + fail + ~json_path:(child root "max_internal_events") + (Printf.sprintf "internal event count is %d; limit is %d" + max_internal_events Resource_limits.internal_events) + else Ok (currencies, catalog) + +let initial_portfolio ~root ~currencies ~catalog ~instruments ~risk initial = + let path = child root "initial_portfolio" in + let cash_currencies = List.map fst initial.Initial_portfolio.cash in + let fx_currencies = List.map fst initial.fx_rates in + let expected_currencies = List.sort String.compare currencies in + if List.sort String.compare cash_currencies <> expected_currencies then + fail ~json_path:(child path "cash") + "initial cash must contain every scenario currency exactly once" + else if + not + (List.for_all + (fun currency -> List.mem currency fx_currencies) + expected_currencies) + then + fail ~json_path:(child path "fx_rates") + "initial FX rates must contain every scenario currency" + else + let instrument_map = + List.fold_left + (fun map instrument -> + Id.Instrument.Map.add instrument.Instrument.id instrument map) + Id.Instrument.Map.empty instruments + in + let* () = + List.fold_left + (fun result (position : Initial_portfolio.position) -> + let* () = result in + if not (Id.Instrument.Set.mem position.instrument_id catalog) then + fail ~json_path:(child path "positions") + "initial position refers to an unknown instrument" + else + match + Id.Instrument.Map.find_opt position.instrument_id instrument_map + with + | None -> assert false + | Some instrument -> + if + not + (Scalar.Quantity.is_multiple position.quantity + ~lot:instrument.Instrument.lot_size) + then + fail ~json_path:(child path "positions") + "initial position quantity is not aligned to its \ + instrument lot" + else + Risk.check_position_for risk position.instrument_id + position.quantity + |> at (child path "positions")) + (Ok ()) initial.positions + in + let* () = + List.fold_left + (fun result (instrument_id, mark) -> + let* () = result in + if not (Id.Instrument.Set.mem instrument_id catalog) then + fail ~json_path:(child path "marks") + "initial mark refers to an unknown instrument" + else + match Id.Instrument.Map.find_opt instrument_id instrument_map with + | None -> assert false + | Some instrument -> + if Scalar.Price.is_multiple mark ~tick:instrument.tick_size then + Ok () + else + fail ~json_path:(child path "marks") + "initial mark is not aligned to its instrument tick size") + (Ok ()) initial.marks + in + let* account = Account.of_initial_portfolio initial |> at path in + let* valuation = + Account.value account ~instruments ~marks:initial.marks + ~fx_rates:initial.fx_rates + |> at path + in + Risk.check_initial risk valuation |> at path + +let changes_orders = function + | Strategy.Target_weights _ | Strategy.Target_quantities _ + | Strategy.Submit_order _ | Strategy.Cancel_order _ -> + true + | Strategy.Emit_metric _ -> false + +let validate_portfolio_target ~json_path risk catalog = function + | Strategy.Target_weights targets -> + let ids = + List.map + (fun (target : Strategy.weight_target) -> target.instrument_id) + targets + in + let unique = List.sort_uniq Id.Instrument.compare ids in + if List.length unique <> List.length ids then + fail ~json_path + "target_weights must contain each instrument exactly once" + else if + not (Id.Instrument.Set.equal catalog (Id.Instrument.Set.of_list ids)) + then + fail ~json_path "target_weights must cover every configured instrument" + else + let gross = + List.fold_left + (fun result (target : Strategy.weight_target) -> + let* total = result in + let* absolute = + Scalar.Weight.absolute target.Strategy.weight |> at json_path + in + Scalar.Weight.add total absolute |> at json_path) + (Ok Scalar.Weight.zero) targets + in + let* gross = gross in + if + Int64.compare + (Scalar.Weight.to_micros gross) + (Scalar.Ratio.to_micros (Risk.max_leverage risk)) + > 0 + then fail ~json_path "target gross weight exceeds maximum leverage" + else Ok () + | Strategy.Target_quantities targets -> + let ids = + List.map + (fun (target : Strategy.quantity_target) -> target.instrument_id) + targets + in + let unique = List.sort_uniq Id.Instrument.compare ids in + if List.length unique <> List.length ids then + fail ~json_path + "target_quantities must contain each instrument exactly once" + else if + not (Id.Instrument.Set.equal catalog (Id.Instrument.Set.of_list ids)) + then + fail ~json_path + "target_quantities must cover every configured instrument" + else + List.fold_left + (fun result (target : Strategy.quantity_target) -> + let* () = result in + match Risk.instrument risk target.Strategy.instrument_id with + | None -> + fail ~json_path + "target quantity refers to an unknown instrument" + | Some instrument -> + if + not + (Scalar.Quantity.is_multiple target.quantity + ~lot:instrument.Instrument.lot_size) + then + fail ~json_path + "target quantity is not aligned to its instrument lot" + else + Risk.check_position_for risk target.instrument_id + target.quantity + |> at json_path) + (Ok ()) targets + | Strategy.Submit_order request -> ( + if not (Id.Instrument.Set.mem request.Order.instrument_id catalog) then + fail ~json_path "order refers to an unknown instrument" + else + match + ( Risk.instrument risk request.instrument_id, + Risk.max_order_quantity_for risk request.instrument_id ) + with + | None, _ -> fail ~json_path "order refers to an unknown instrument" + | _, None -> fail ~json_path "order has no instrument risk policy" + | Some instrument, Some order_limit -> ( + if Scalar.Quantity.compare request.quantity order_limit > 0 then + fail ~json_path + "order exceeds the instrument maximum order quantity" + else if + not + (Scalar.Quantity.is_multiple request.quantity + ~lot:instrument.Instrument.lot_size) + then + fail ~json_path + "order quantity is not aligned to the instrument lot size" + else + match request.kind with + | Order.Market -> Ok () + | Order.Limit price | Order.Stop price -> + if Scalar.Price.is_multiple price ~tick:instrument.tick_size + then Ok () + else + fail ~json_path + "order price is not aligned to the instrument tick size" + | Order.Stop_limit { trigger_price; limit_price } -> + if + Scalar.Price.is_multiple trigger_price + ~tick:instrument.tick_size + && Scalar.Price.is_multiple limit_price + ~tick:instrument.tick_size + then Ok () + else + fail ~json_path + "order price is not aligned to the instrument tick size")) + | Strategy.Cancel_order _ | Strategy.Emit_metric _ -> Ok () + +let validate_slices_at ~paths ~base_currency ~currencies ~instruments slices = + let catalog = + List.map (fun instrument -> instrument.Instrument.id) instruments + |> Id.Instrument.Set.of_list + in + let instrument_map = + List.fold_left + (fun map instrument -> + Id.Instrument.Map.add instrument.Instrument.id instrument map) + Id.Instrument.Map.empty instruments + in + let expected_currencies = String_set.of_list currencies in + let one = Scalar.Price.of_decimal_string "1" |> Result.get_ok in + let rec validate index previous_sequence previous_end previous_received + action_ids = function + | [] -> Ok () + | market_slice :: remaining -> + let root = List.nth paths index in + let ids = + List.map + (fun bar -> bar.Bar.instrument_id) + market_slice.Market_slice.bars + |> Id.Instrument.Set.of_list + in + let fx_currencies = + List.map + (fun mark -> mark.Market_slice.currency) + market_slice.Market_slice.fx_rates + |> String_set.of_list + in + let actions_valid = + List.for_all + (fun action -> + Id.Instrument.Set.mem action.Corporate_action.instrument_id + 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 id -> Id.Corporate_action.Set.mem id action_ids) + current_ids + in + let bars_aligned = + List.for_all + (fun bar -> + match + Id.Instrument.Map.find_opt bar.Bar.instrument_id instrument_map + with + | None -> false + | Some instrument -> + List.for_all + (fun price -> + Scalar.Price.is_multiple price ~tick:instrument.tick_size) + [ + bar.open_price; + bar.high_price; + bar.low_price; + bar.close_price; + ] + && Option.for_all + (fun volume -> + Scalar.Quantity.is_multiple volume + ~lot:instrument.lot_size) + bar.volume) + market_slice.bars + in + let market_events_valid = + List.for_all + (fun (event : Market_event.t) -> + match + Id.Instrument.Map.find_opt event.instrument_id instrument_map + with + | None -> false + | Some instrument -> + let prices, quantities = + match event.kind with + | Market_event.Quote + { bid_price; bid_quantity; ask_price; ask_quantity } -> + ( [ bid_price; ask_price ], + [ bid_quantity; ask_quantity ] ) + | Trade { price; quantity; _ } -> ([ price ], [ quantity ]) + in + List.for_all + (fun price -> + Scalar.Price.is_multiple price ~tick:instrument.tick_size) + prices + && List.for_all + (fun quantity -> + Scalar.Quantity.is_multiple quantity + ~lot:instrument.lot_size) + quantities + && Ptime.compare event.event_at market_slice.start_at >= 0 + && Ptime.compare event.event_at market_slice.end_at <= 0 + && Ptime.compare event.available_at market_slice.available_at + <= 0 + && Ptime.compare event.received_at market_slice.received_at + <= 0) + market_slice.market_events + in + if not (Id.Instrument.Set.equal catalog ids) then + fail ~json_path:(child root "bars") + "each market slice must contain every configured instrument" + else if not (String_set.subset expected_currencies fx_currencies) then + fail ~json_path:(child root "fx_rates") + "each market slice must contain every scenario currency FX rate" + else if + not + (Option.exists + (fun rate -> Scalar.Price.equal rate one) + (Market_slice.fx_rate market_slice base_currency)) + then + fail ~json_path:(child root "fx_rates") + "the base-currency FX rate must equal one" + else if not actions_valid then + 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") + "corporate action IDs must be unique across the scenario" + else if not bars_aligned then + fail ~json_path:(child root "bars") + "market prices and volumes must align with instrument increments" + else if not market_events_valid then + fail + ~json_path:(child root "market_events") + "market events must be known, aligned, and observable within the \ + slice" + else if + Option.exists + (fun sequence -> + Int64.compare market_slice.slice_sequence sequence <= 0) + previous_sequence + then + fail + ~json_path:(child root "slice_sequence") + "market slice sequence must increase" + else if + Option.exists + (fun end_at -> Ptime.compare market_slice.start_at end_at < 0) + previous_end + then + fail ~json_path:(child root "start_at") + "market slice start must not precede previous end" + else if + Option.exists + (fun received_at -> + Ptime.compare market_slice.received_at received_at < 0) + previous_received + then + fail ~json_path:(child root "received_at") + "market slice receipt time must not move backward" + else + let action_ids = + List.fold_left + (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) + action_ids remaining + in + validate 0 None None None Id.Corporate_action.Set.empty slices + +let validate_schedule ~root risk catalog schedule slices = + let rec index_slices index = function + | [] -> index + | [ anchor ] -> + Int64_map.add anchor.Market_slice.slice_sequence (anchor, None) index + | anchor :: (next :: _ as remaining) -> + let index = + Int64_map.add anchor.Market_slice.slice_sequence (anchor, Some next) + index + in + index_slices index remaining + in + let slice_index = index_slices Int64_map.empty slices in + let validate_item index sequence intents = + let item_root = Printf.sprintf "%s[%d]" root index in + if Int64.compare sequence 0L <= 0 then + fail + ~json_path:(child item_root "after_slice_sequence") + "scheduled slice sequence must be positive" + else + match Int64_map.find_opt sequence slice_index with + | None -> + fail + ~json_path:(child item_root "after_slice_sequence") + (Printf.sprintf + "scheduled intents refer to missing market slice sequence %Ld" + sequence) + | Some (anchor, next) -> ( + let intents_path = child item_root "intents" in + let* () = + List.fold_left + (fun result intent -> + let* () = result in + validate_portfolio_target ~json_path:intents_path risk catalog + intent) + (Ok ()) intents + in + match next with + | Some next + when List.exists changes_orders intents + && Ptime.compare anchor.received_at next.start_at > 0 -> + fail ~json_path:intents_path + (Printf.sprintf + "scheduled order intent after slice %Ld is received after \ + the next executable market slice starts" + sequence) + | None | Some _ -> Ok ()) + in + let rec validate index previous = function + | [] -> Ok () + | (sequence, intents) :: remaining -> + if + Option.exists + (fun prior -> Int64.compare sequence prior <= 0) + previous + then + fail + ~json_path:(Printf.sprintf "%s[%d].after_slice_sequence" root index) + "schedule sequences must increase" + else + let* () = validate_item index sequence intents in + validate (index + 1) (Some sequence) remaining + in + validate 0 None schedule + +let batch ~root ~base_currency ~currencies ~instruments ~risk ~catalog ~schedule + ~slices = + let slice_paths = + List.mapi (fun index _ -> Printf.sprintf "%s.slices[%d]" root index) slices + in + let* () = + validate_slices_at ~paths:slice_paths ~base_currency ~currencies + ~instruments slices + in + validate_schedule ~root:(child root "schedule") risk catalog schedule slices + +let stream_item ~root ~base_currency ~instruments ~risk ~previous_slice + ~previous_intents ~prior_action_ids ~(market_slice : Market_slice.t) + ~intents = + let catalog = + List.map (fun instrument -> instrument.Instrument.id) instruments + |> Id.Instrument.Set.of_list + in + let current_slice_path = child root "market_slice" in + let* action_ids = + List.fold_left + (fun result action -> + let* ids = result in + if Id.Corporate_action.Set.mem action.Corporate_action.id ids then + fail + ~json_path:(child current_slice_path "corporate_actions") + "corporate action IDs must be unique across the scenario stream" + 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 + (fun instrument -> instrument.Instrument.quote_currency) + instruments + |> List.sort_uniq String.compare + in + let slices, paths = + match previous_slice with + | None -> ([ market_slice ], [ current_slice_path ]) + | Some previous -> + ([ previous; market_slice ], [ current_slice_path; current_slice_path ]) + in + let* () = + validate_slices_at ~paths ~base_currency ~currencies ~instruments slices + in + let intents_path = child root "intents" in + let* () = + List.fold_left + (fun result intent -> + let* () = result in + validate_portfolio_target ~json_path:intents_path risk catalog intent) + (Ok ()) intents + in + let* () = + match previous_slice with + | Some previous + when List.exists changes_orders previous_intents + && Ptime.compare previous.received_at market_slice.start_at > 0 -> + fail + ~json_path:(child current_slice_path "start_at") + (Printf.sprintf + "scheduled order intent after slice %Ld is received after the \ + next executable market slice starts" + previous.slice_sequence) + | None | Some _ -> Ok () + in + Ok action_ids diff --git a/lib/scenario_validation.mli b/lib/scenario_validation.mli new file mode 100644 index 0000000..63cf608 --- /dev/null +++ b/lib/scenario_validation.mli @@ -0,0 +1,42 @@ +(** Cross-field and cross-record scenario semantics. *) + +val header : + root:string -> + base_currency:string -> + initial_cash:(string * Scalar.Money.t) list -> + instruments:Instrument.t list -> + venue_calendars:Venue_calendar.t list -> + max_internal_events:int -> + (string list * Id.Instrument.Set.t, Scenario_shape.error) result + +val initial_portfolio : + root:string -> + currencies:string list -> + catalog:Id.Instrument.Set.t -> + instruments:Instrument.t list -> + risk:Risk.t -> + Initial_portfolio.t -> + (unit, Scenario_shape.error) result + +val batch : + root:string -> + base_currency:string -> + currencies:string list -> + instruments:Instrument.t list -> + risk:Risk.t -> + catalog:Id.Instrument.Set.t -> + schedule:(int64 * Strategy.intent list) list -> + slices:Market_slice.t list -> + (unit, Scenario_shape.error) result + +val stream_item : + root:string -> + base_currency:string -> + instruments:Instrument.t list -> + risk:Risk.t -> + previous_slice:Market_slice.t option -> + previous_intents:Strategy.intent list -> + prior_action_ids:Id.Corporate_action.Set.t -> + market_slice:Market_slice.t -> + intents:Strategy.intent list -> + (Id.Corporate_action.Set.t, Scenario_shape.error) result diff --git a/lib/settlement.ml b/lib/settlement.ml new file mode 100644 index 0000000..0066c30 --- /dev/null +++ b/lib/settlement.ml @@ -0,0 +1,215 @@ +type cash_buying_power = Total_cash | Settled_cash +type position_availability = Total_positions | Settled_positions + +type calendar = { + calendar_id : string; + version : string; + business_dates : string list; +} + +type rule = { + instrument_id : Id.Instrument.t; + calendar_id : string; + lag_business_days : int; +} + +type policy = { + cash_buying_power : cash_buying_power; + position_availability : position_availability; + calendars : calendar list; + rules : rule list; +} + +type status = + | Pending + | Settled of Ptime.t + | Failed of { failed_at : Ptime.t; reason : string } + +type instruction = { + instruction_id : string; + fill_id : Id.Fill.t; + instrument_id : Id.Instrument.t; + currency : string; + cash_movement : Scalar.Money.t; + position_movement : Scalar.Quantity.t; + trade_date : string; + due_date : string; + status : status; +} + +type failure = { instruction_id : string; reason : string } + +let ( let* ) result function_ = + match result with Ok value -> function_ value | Error _ as error -> error + +let valid_token value = + String.length value > 0 + && String.for_all + (fun character -> + let code = Char.code character in + code >= 0x21 && code <> 0x7f) + value + +let valid_text value = + String.length value > 0 + && String.equal value (String.trim value) + && String.for_all + (fun character -> + let code = Char.code character in + code >= 0x20 && code <> 0x7f) + value + +let valid_date value = + String.length value = 10 + && value.[4] = '-' + && value.[7] = '-' + && Result.is_ok (Ptime.of_rfc3339 (value ^ "T00:00:00Z")) + +let calendar ~calendar_id ~version ~business_dates = + if not (valid_token calendar_id) then + Error "settlement calendar_id must not be empty or contain whitespace" + else if not (String.equal version "1") then + Error (Printf.sprintf "unsupported settlement calendar version %S" version) + else if business_dates = [] then + Error "settlement calendar must define at least one business date" + else if not (List.for_all valid_date business_dates) then + Error "settlement business dates must use canonical YYYY-MM-DD dates" + else if List.sort_uniq String.compare business_dates <> business_dates then + Error "settlement business dates must be unique and increasing" + else Ok { calendar_id; version; business_dates } + +let rule ~instrument_id ~calendar_id ~lag_business_days = + if not (valid_token calendar_id) then + Error "settlement rule calendar_id must not be empty or contain whitespace" + else if lag_business_days < 0 || lag_business_days > 30 then + Error "settlement lag_business_days must be between zero and 30" + else Ok { instrument_id; calendar_id; lag_business_days } + +let policy ~cash_buying_power ~position_availability + ~(calendars : calendar list) ~(rules : rule list) = + if calendars = [] then Error "settlement policy must define calendars" + else if rules = [] then Error "settlement policy must define instrument rules" + else + let calendar_ids = + List.map (fun (value : calendar) -> value.calendar_id) calendars + in + let instruments = + List.map (fun (value : rule) -> value.instrument_id) rules + in + if + List.sort_uniq String.compare calendar_ids + <> List.sort String.compare calendar_ids + then Error "settlement calendar IDs must be unique" + else if + List.sort_uniq Id.Instrument.compare instruments + <> List.sort Id.Instrument.compare instruments + then Error "settlement rules must name unique instruments" + else if + not + (List.for_all + (fun (value : rule) -> List.mem value.calendar_id calendar_ids) + rules) + then Error "settlement rule refers to an unknown calendar" + else Ok { cash_buying_power; position_availability; calendars; rules } + +let date_of_timestamp timestamp = + let year, month, day = Ptime.to_date timestamp in + Printf.sprintf "%04d-%02d-%02d" year month day + +let instruction policy (fill : Fill.t) = + let* rule = + match + List.find_opt + (fun (rule : rule) -> + Id.Instrument.equal rule.instrument_id fill.instrument_id) + policy.rules + with + | Some value -> Ok value + | None -> Error "fill instrument has no settlement rule" + in + let* calendar = + match + List.find_opt + (fun (calendar : calendar) -> + String.equal calendar.calendar_id rule.calendar_id) + policy.calendars + with + | Some value -> Ok value + | None -> Error "settlement rule calendar is unavailable" + in + let trade_date = date_of_timestamp fill.executed_at in + let* trade_index = + let rec find index = function + | [] -> Error "fill trade date is absent from its settlement calendar" + | date :: remaining -> + if String.equal date trade_date then Ok index + else find (index + 1) remaining + in + find 0 calendar.business_dates + in + let* due_date = + match + List.nth_opt calendar.business_dates (trade_index + rule.lag_business_days) + with + | Some value -> Ok value + | None -> + Error "settlement calendar does not cover the instruction due date" + in + let* cash_movement, position_movement = + match fill.side with + | Order.Buy -> + let* debit = Scalar.Money.add fill.notional fill.fee in + let* cash = Scalar.Money.negate debit in + Ok (cash, fill.quantity) + | Order.Sell -> + let* cash = Scalar.Money.subtract fill.notional fill.fee in + let* position = Scalar.Quantity.negate fill.quantity in + Ok (cash, position) + in + Ok + { + instruction_id = Id.Fill.to_string fill.id ^ "-settlement"; + fill_id = fill.id; + instrument_id = fill.instrument_id; + currency = fill.quote_currency; + cash_movement; + position_movement; + trade_date; + due_date; + status = Pending; + } + +let failure ~instruction_id ~reason = + if not (valid_token instruction_id) then + Error + "settlement failure instruction_id must not be empty or contain \ + whitespace" + else if not (valid_text reason) then + Error "settlement failure reason must be nonempty, trimmed text" + else Ok { instruction_id; reason } + +let settle instruction ~settled_at = + match instruction.status with + | Pending -> Ok { instruction with status = Settled settled_at } + | Settled _ | Failed _ -> Error "settlement instruction is already terminal" + +let fail instruction ~failed_at ~reason = + match instruction.status with + | Pending -> Ok { instruction with status = Failed { failed_at; reason } } + | Settled _ | Failed _ -> Error "settlement instruction is already terminal" + +let is_due instruction timestamp = + String.compare (date_of_timestamp timestamp) instruction.due_date >= 0 + +let cash_buying_power_to_string = function + | Total_cash -> "total_cash" + | Settled_cash -> "settled_cash" + +let position_availability_to_string = function + | Total_positions -> "total_positions" + | Settled_positions -> "settled_positions" + +let status_to_string = function + | Pending -> "pending" + | Settled _ -> "settled" + | Failed _ -> "failed" diff --git a/lib/settlement.mli b/lib/settlement.mli new file mode 100644 index 0000000..9d97a5a --- /dev/null +++ b/lib/settlement.mli @@ -0,0 +1,77 @@ +(** Deterministic trade-settlement calendars, policies, and instructions. *) + +type cash_buying_power = Total_cash | Settled_cash +type position_availability = Total_positions | Settled_positions + +type calendar = private { + calendar_id : string; + version : string; + business_dates : string list; +} + +type rule = private { + instrument_id : Id.Instrument.t; + calendar_id : string; + lag_business_days : int; +} + +type policy = private { + cash_buying_power : cash_buying_power; + position_availability : position_availability; + calendars : calendar list; + rules : rule list; +} + +type status = + | Pending + | Settled of Ptime.t + | Failed of { failed_at : Ptime.t; reason : string } + +type instruction = private { + instruction_id : string; + fill_id : Id.Fill.t; + instrument_id : Id.Instrument.t; + currency : string; + cash_movement : Scalar.Money.t; + position_movement : Scalar.Quantity.t; + trade_date : string; + due_date : string; + status : status; +} + +type failure = private { instruction_id : string; reason : string } + +val calendar : + calendar_id:string -> + version:string -> + business_dates:string list -> + (calendar, string) result + +val rule : + instrument_id:Id.Instrument.t -> + calendar_id:string -> + lag_business_days:int -> + (rule, string) result + +val policy : + cash_buying_power:cash_buying_power -> + position_availability:position_availability -> + calendars:calendar list -> + rules:rule list -> + (policy, string) result + +val instruction : policy -> Fill.t -> (instruction, string) result +val failure : instruction_id:string -> reason:string -> (failure, string) result +val settle : instruction -> settled_at:Ptime.t -> (instruction, string) result + +val fail : + instruction -> + failed_at:Ptime.t -> + reason:string -> + (instruction, string) result + +val date_of_timestamp : Ptime.t -> string +val is_due : instruction -> Ptime.t -> bool +val cash_buying_power_to_string : cash_buying_power -> string +val position_availability_to_string : position_availability -> string +val status_to_string : status -> string diff --git a/lib/sha256.ml b/lib/sha256.ml index f602463..1b7d5ac 100644 --- a/lib/sha256.ml +++ b/lib/sha256.ml @@ -247,4 +247,8 @@ let digest_file path = try In_channel.with_open_bin path (fun channel -> digest_channel channel |> Result.ok) - with Sys_error message -> Error ("could not hash scenario: " ^ message) + with Sys_error message as exception_ -> + Error + (Diagnostic.of_exception ~code:Diagnostic.Input_io ~phase:Diagnostic.Input + ~message:("could not hash scenario: " ^ message) + exception_) diff --git a/lib/sha256.mli b/lib/sha256.mli index 9a9da58..a31ebbc 100644 --- a/lib/sha256.mli +++ b/lib/sha256.mli @@ -2,4 +2,4 @@ val digest_string : string -> string val digest_channel : in_channel -> string -val digest_file : string -> (string, string) result +val digest_file : string -> (string, Diagnostic.t) result diff --git a/lib/strategy.ml b/lib/strategy.ml index ba98e14..85eadf0 100644 --- a/lib/strategy.ml +++ b/lib/strategy.ml @@ -8,6 +8,8 @@ type context = { and marked_position = { instrument_id : Id.Instrument.t; quantity : Scalar.Quantity.t; + settled_quantity : Scalar.Quantity.t; + unsettled_quantity : Scalar.Quantity.t; mark : Scalar.Price.t; base_market_value : Scalar.Money.t; weight : Scalar.Weight.t option; @@ -24,6 +26,7 @@ and portfolio = { cash_weight : Scalar.Weight.t option; cash_balances : Account.cash_attribution list; positions : marked_position list; + group_exposures : Risk.group_exposure list; } type event = @@ -47,7 +50,7 @@ type intent = | Target_quantities of quantity_target list | Submit_order of Order.request | Cancel_order of Id.Order.t - | Emit_metric of { name : string; value : string } + | Emit_metric of Metric.t let ( let* ) result function_ = match result with Ok value -> function_ value | Error _ as error -> error @@ -56,7 +59,8 @@ let weight ~equity value = if Scalar.Money.compare equity Scalar.Money.zero <= 0 then Ok None else Scalar.Money.weight_toward_zero value ~equity |> Result.map Option.some -let context ~now ~(valuation : Account.valuation) ~working_orders ~latest_bars = +let context ~now ~(valuation : Account.valuation) ~group_exposures + ~working_orders ~latest_bars = let* cash_weight = weight ~equity:valuation.equity valuation.cash in let* positions = List.fold_left @@ -69,6 +73,8 @@ let context ~now ~(valuation : Account.valuation) ~working_orders ~latest_bars = ({ instrument_id = position.instrument_id; quantity = position.quantity; + settled_quantity = position.settled_quantity; + unsettled_quantity = position.unsettled_quantity; mark = position.mark; base_market_value = position.base_market_value; weight = position_weight; @@ -94,6 +100,7 @@ let context ~now ~(valuation : Account.valuation) ~working_orders ~latest_bars = cash_weight; cash_balances = valuation.cash_balances; positions; + group_exposures; } in Ok { now; portfolio; working_orders; latest_bars } @@ -121,6 +128,8 @@ let working_orders context = context.working_orders let latest_bar context instrument_id = Id.Instrument.Map.find_opt instrument_id context.latest_bars +let group_exposures context = context.portfolio.group_exposures + module type S = sig type state diff --git a/lib/strategy.mli b/lib/strategy.mli index fce158e..ece4bd7 100644 --- a/lib/strategy.mli +++ b/lib/strategy.mli @@ -5,6 +5,8 @@ type context type marked_position = private { instrument_id : Id.Instrument.t; quantity : Scalar.Quantity.t; + settled_quantity : Scalar.Quantity.t; + unsettled_quantity : Scalar.Quantity.t; mark : Scalar.Price.t; base_market_value : Scalar.Money.t; weight : Scalar.Weight.t option; @@ -21,6 +23,7 @@ type portfolio = private { cash_weight : Scalar.Weight.t option; cash_balances : Account.cash_attribution list; positions : marked_position list; + group_exposures : Risk.group_exposure list; } type event = @@ -44,11 +47,12 @@ type intent = | Target_quantities of quantity_target list | Submit_order of Order.request | Cancel_order of Id.Order.t - | Emit_metric of { name : string; value : string } + | Emit_metric of Metric.t val context : now:Ptime.t -> valuation:Account.valuation -> + group_exposures:Risk.group_exposure list -> working_orders:Order.t list -> latest_bars:Bar.t list -> (context, string) result @@ -60,6 +64,7 @@ val cash_balances : context -> (string * Scalar.Money.t) list val position : context -> Id.Instrument.t -> Scalar.Quantity.t val working_orders : context -> Order.t list val latest_bar : context -> Id.Instrument.t -> Bar.t option +val group_exposures : context -> Risk.group_exposure list module type S = sig type state diff --git a/lib/strategy_process.ml b/lib/strategy_process.ml index c5054c9..a6fe339 100644 --- a/lib/strategy_process.ml +++ b/lib/strategy_process.ml @@ -2,59 +2,275 @@ type t = { input : Eio.Flow.sink_ty Eio.Resource.t; close_input : unit -> unit; output : Eio.Buf_read.t; - await_process : unit -> Eio.Process.exit_status; + child : child; clock : float Eio.Time.clock_ty Eio.Resource.t; transcript : Strategy_transcript.t; + effects : Boundary_effects.t; timeout : float; mutable next_sequence : int64; } +and child = { + process : Eio_unix.Process.ty Eio.Resource.t; + pgid : int; + clock : float Eio.Time.clock_ty Eio.Resource.t; + effects : Boundary_effects.t; + mutable status : Eio.Process.exit_status option; +} + +external enable_child_subreaper : unit -> int + = "trading_engine_enable_child_subreaper" + +let graceful_termination_timeout = 1.0 +let forced_reap_timeout = 5.0 +let process_poll_interval = 0.01 + let ( let* ) result function_ = match result with Ok value -> function_ value | Error _ as error -> error let valid_timeout value = Float.is_finite value && Float.compare value 0.0 > 0 +let diagnostic ?sequence ~code message = + Diagnostic.make ?sequence ~code ~phase:Diagnostic.Strategy message + let next_sequence session = if Int64.equal session.next_sequence Int64.max_int then - Error "strategy protocol sequence is exhausted" + Error + (diagnostic ~sequence:session.next_sequence + ~code:Diagnostic.Strategy_protocol + "strategy protocol sequence is exhausted") else let current = session.next_sequence in session.next_sequence <- Int64.succ current; Ok current -let exception_message stage exception_ = - match exception_ with - | End_of_file -> stage ^ ": external strategy closed stdout" - | Eio.Buf_read.Buffer_limit_exceeded -> - stage ^ ": strategy response exceeds the maximum message size" - | _ -> stage ^ ": " ^ Printexc.to_string exception_ +let exception_diagnostic ?sequence stage exception_ = + let code, message = + match exception_ with + | End_of_file -> + ( Diagnostic.Strategy_protocol, + stage ^ ": external strategy closed stdout" ) + | Eio.Buf_read.Buffer_limit_exceeded -> + ( Diagnostic.Resource_limit, + Printf.sprintf + "%s: strategy response exceeds the maximum message size (%d bytes)" + stage Strategy_protocol.max_message_bytes ) + | _ -> + ( Diagnostic.Strategy_process, + stage ^ ": " ^ Printexc.to_string exception_ ) + in + Diagnostic.of_exception ?sequence ~code ~phase:Diagnostic.Strategy ~message + exception_ + +let await_child (child : child) = + match child.status with + | Some status -> status + | None -> + let status = + Boundary_effects.perform child.effects Boundary_effects.Reap_process + (fun () -> Eio.Process.await child.process) + in + child.status <- Some status; + status + +let await_child_for (child : child) timeout = + match child.status with + | Some _ as status -> status + | None -> ( + try + match + Eio.Time.with_timeout child.clock timeout (fun () -> + Ok (await_child child)) + with + | Ok status -> Some status + | Error `Timeout -> None + with exception_ -> + raise + (Failure + (Diagnostic.to_human + (exception_diagnostic "waiting for external strategy" exception_))) + ) + +let process_group_exists pgid = + try + Unix.kill (-pgid) 0; + true + with + | Unix.Unix_error (Unix.ESRCH, _, _) -> false + | Unix.Unix_error (Unix.EPERM, _, _) -> true + +let signal_process_group pgid signal = + try + Unix.kill (-pgid) signal; + Ok () + with + | Unix.Unix_error (Unix.ESRCH, _, _) -> Ok () + | Unix.Unix_error (code, operation, target) as exception_ -> + Error + (Diagnostic.of_exception ~code:Diagnostic.Strategy_process + ~phase:Diagnostic.Strategy + ~message: + (Printf.sprintf + "could not signal external strategy process group: %s(%s): %s" + operation target (Unix.error_message code)) + exception_) + +let rec reap_descendants pgid = + try + match Unix.waitpid [ Unix.WNOHANG ] (-pgid) with + | 0, _ -> () + | _, _ -> reap_descendants pgid + with Unix.Unix_error (Unix.ECHILD, _, _) -> () + +let rec wait_for_process_group (child : child) deadline = + reap_descendants child.pgid; + if not (process_group_exists child.pgid) then true + else + let remaining = deadline -. Eio.Time.now child.clock in + if Float.compare remaining 0.0 <= 0 then false + else ( + Eio.Time.sleep child.clock (Float.min process_poll_interval remaining); + wait_for_process_group child deadline) + +let terminate_process_group_direct (child : child) = + Eio.Cancel.protect (fun () -> + let graceful_deadline = + Eio.Time.now child.clock +. graceful_termination_timeout + in + let* () = signal_process_group child.pgid Sys.sigterm in + let direct_status = + match child.status with + | Some _ as status -> status + | None -> + let remaining = graceful_deadline -. Eio.Time.now child.clock in + if Float.compare remaining 0.0 <= 0 then None + else await_child_for child remaining + in + let group_stopped = + match direct_status with + | None -> false + | Some _ -> wait_for_process_group child graceful_deadline + in + if group_stopped then Ok () + else + let forced_deadline = Eio.Time.now child.clock +. forced_reap_timeout in + let* () = signal_process_group child.pgid Sys.sigkill in + let direct_status = + match direct_status with + | Some _ as status -> status + | None -> await_child_for child forced_reap_timeout + in + match direct_status with + | None -> + Error + (diagnostic ~code:Diagnostic.Strategy_process + "external strategy did not exit after forced termination") + | Some _ -> + if wait_for_process_group child forced_deadline then Ok () + else + Error + (diagnostic ~code:Diagnostic.Strategy_process + "external strategy descendants remained after forced \ + termination")) + +let terminate_process_group (child : child) = + try + Boundary_effects.perform child.effects Boundary_effects.Terminate_process + (fun () -> terminate_process_group_direct child) + with exception_ -> + Error (exception_diagnostic "terminating external strategy" exception_) + +let append_cleanup_error result child = + match terminate_process_group child with + | Ok () -> result + | Error cleanup -> ( + match result with + | Ok _ -> Error cleanup + | Error original -> Error (Diagnostic.combine original cleanup)) + +let rejection_evidence response = + let observed_bytes = String.length response in + let captured_bytes = + Int.min observed_bytes Strategy_transcript.max_rejection_prefix_bytes + in + ( String.sub response 0 captured_bytes, + observed_bytes, + captured_bytes < observed_bytes ) + +let buffered_rejection_evidence output = + let observed_bytes = Eio.Buf_read.buffered_bytes output in + let captured_bytes = + Int.min observed_bytes Strategy_transcript.max_rejection_prefix_bytes + in + ( Eio.Buf_read.take captured_bytes output, + observed_bytes, + captured_bytes < observed_bytes ) + +let reject_response session ~expected_sequence rejection + (raw_prefix, observed_bytes, truncated) = + match + Strategy_transcript.append_rejection session.transcript ~expected_sequence + ~diagnostic:rejection ~raw_prefix ~observed_bytes ~truncated + with + | Ok () -> Error rejection + | Error artifact -> Error (Diagnostic.combine rejection artifact) let exchange session ~stage ~expected_sequence request = + let request_document = Strategy_protocol.message_to_string request in + let request_bytes = String.length request_document in + let* () = + if request_bytes <= Strategy_protocol.max_message_bytes then Ok () + else + Error + (diagnostic ~sequence:expected_sequence ~code:Diagnostic.Resource_limit + (Printf.sprintf "%s: strategy message is %d bytes; limit is %d bytes" + stage request_bytes Strategy_protocol.max_message_bytes)) + in let* () = Strategy_transcript.append session.transcript ~direction:Strategy_protocol.Engine_to_strategy request in - let request_line = Strategy_protocol.message_to_string request ^ "\n" in + let request_line = request_document ^ "\n" in let response = try match Eio.Time.with_timeout session.clock session.timeout (fun () -> - Eio.Flow.copy_string request_line session.input; - Ok (Eio.Buf_read.line session.output)) + Ok + (Boundary_effects.perform session.effects + Boundary_effects.Exchange_process (fun () -> + Eio.Flow.copy_string request_line session.input; + Eio.Buf_read.line session.output))) with | Ok response -> Ok response - | Error `Timeout -> Error (stage ^ ": external strategy timed out") - with exception_ -> Error (exception_message stage exception_) + | Error `Timeout -> + Error + (diagnostic ~sequence:expected_sequence + ~code:Diagnostic.Strategy_timeout + (stage ^ ": external strategy timed out")) + with + | (End_of_file | Eio.Buf_read.Buffer_limit_exceeded) as exception_ -> + reject_response session ~expected_sequence + (exception_diagnostic ~sequence:expected_sequence stage exception_) + (buffered_rejection_evidence session.output) + | exception_ -> + Error + (exception_diagnostic ~sequence:expected_sequence stage exception_) in let* response = response in - let* response, response_json = + match Strategy_protocol.response_of_string ~expected_sequence response - in - let* () = - Strategy_transcript.append session.transcript - ~direction:Strategy_protocol.Strategy_to_engine response_json - in - Ok response + |> Result.map_error + (Diagnostic.annotate ~sequence:expected_sequence ~json_path:"$") + with + | Error rejection -> + reject_response session ~expected_sequence rejection + (rejection_evidence response) + | Ok (response, response_json) -> + let* () = + Strategy_transcript.append session.transcript + ~direction:Strategy_protocol.Strategy_to_engine response_json + in + Ok response let exchange_at session ~stage ~sequence make_request = exchange session ~stage ~expected_sequence:sequence (make_request ~sequence) @@ -69,9 +285,13 @@ let initialize session initialization = match response with | Strategy_protocol.Ready identity -> Ok identity | Failed message -> - Error ("external strategy initialization failed: " ^ message) + Error + (diagnostic ~sequence ~code:Diagnostic.Strategy_protocol + ("external strategy initialization failed: " ^ message)) | Intents _ | Stopped -> - Error "external strategy returned the wrong initialization response" + Error + (diagnostic ~sequence ~code:Diagnostic.Strategy_protocol + "external strategy returned the wrong initialization response") let on_event session context event = let* sequence = next_sequence session in @@ -81,9 +301,14 @@ let on_event session context event = in match response with | Strategy_protocol.Intents intents -> Ok intents - | Failed message -> Error ("external strategy failed: " ^ message) + | Failed message -> + Error + (diagnostic ~sequence ~code:Diagnostic.Strategy_protocol + ("external strategy failed: " ^ message)) | Ready _ | Stopped -> - Error "external strategy returned the wrong event response" + Error + (diagnostic ~sequence ~code:Diagnostic.Strategy_protocol + "external strategy returned the wrong event response") let shutdown session = let* sequence = next_sequence session in @@ -93,9 +318,14 @@ let shutdown session = in match response with | Strategy_protocol.Stopped -> Ok () - | Failed message -> Error ("external strategy shutdown failed: " ^ message) + | Failed message -> + Error + (diagnostic ~sequence ~code:Diagnostic.Strategy_protocol + ("external strategy shutdown failed: " ^ message)) | Ready _ | Intents _ -> - Error "external strategy returned the wrong shutdown response" + Error + (diagnostic ~sequence ~code:Diagnostic.Strategy_protocol + "external strategy returned the wrong shutdown response") let await_exit session = session.close_input (); @@ -103,88 +333,184 @@ let await_exit session = try match Eio.Time.with_timeout session.clock session.timeout (fun () -> - Ok (session.await_process ())) + Ok (await_child session.child)) with | Ok status -> Ok status - | Error `Timeout -> Error "external strategy did not exit after shutdown" + | Error `Timeout -> + Error + (diagnostic ~code:Diagnostic.Strategy_timeout + "external strategy did not exit after shutdown") with exception_ -> - Error (exception_message "waiting for external strategy" exception_) + Error (exception_diagnostic "waiting for external strategy" exception_) in let* status = status in match status with | `Exited 0 -> ( - match Eio.Buf_read.peek_char session.output with + let* () = terminate_process_group session.child in + let trailing_output = + try + match + Eio.Time.with_timeout session.clock session.timeout (fun () -> + Ok (Eio.Buf_read.peek_char session.output)) + with + | Ok value -> Ok value + | Error `Timeout -> + Error + (diagnostic ~code:Diagnostic.Strategy_timeout + "external strategy stdout did not close after exit") + with exception_ -> + Error + (exception_diagnostic "reading final strategy output" exception_) + in + let* trailing_output = trailing_output in + match trailing_output with | None -> Ok () | Some _ -> - Error "external strategy wrote data after its stopped response") + Error + (diagnostic ~code:Diagnostic.Strategy_protocol + "external strategy wrote data after its stopped response")) | `Exited code -> - Error (Printf.sprintf "external strategy exited with code %d" code) + Error + (diagnostic ~code:Diagnostic.Strategy_exit + (Printf.sprintf "external strategy exited with code %d" code)) | `Signaled signal -> - Error (Printf.sprintf "external strategy was killed by signal %d" signal) + Error + (diagnostic ~code:Diagnostic.Strategy_exit + (Printf.sprintf "external strategy was killed by signal %d" signal)) -let with_session ~env ~command ~timeout ~transcript_path - ~(initialization : Strategy_protocol.initialization) use = +let configured_executable ~command ~timeout ~initialization = if not (valid_timeout timeout) then - Error "strategy response timeout must be finite and positive" + Error + (diagnostic ~code:Diagnostic.Strategy_invalid_configuration + "strategy response timeout must be finite and positive") else match command with - | [] -> Error "external strategy command must not be empty" + | [] -> + Error + (diagnostic ~code:Diagnostic.Strategy_invalid_configuration + "external strategy command must not be empty") | executable :: _ when String.length executable = 0 -> - Error "external strategy executable must not be empty" - | executable :: _ -> ( - match Strategy_transcript.create transcript_path with - | Error _ as error -> error - | Ok transcript -> ( - let fail result = - Strategy_transcript.close_preserving_partial transcript; - result - in - try - let result = - Eio.Switch.run ~name:"external-strategy" @@ fun switch -> - let process_manager = Eio.Stdenv.process_mgr env in - let child_stdout, strategy_stdout = - Eio.Process.pipe ~sw:switch process_manager - in - let strategy_stdin, child_stdin = - Eio.Process.pipe ~sw:switch process_manager - in - let process = - Eio.Process.spawn ~sw:switch process_manager - ~stdin:strategy_stdin ~stdout:strategy_stdout - ~stderr:(Eio.Stdenv.stderr env) ~executable command - in - Eio.Flow.close strategy_stdin; - Eio.Flow.close strategy_stdout; - let close_input () = Eio.Flow.close child_stdin in - let session = - { - input = (child_stdin :> Eio.Flow.sink_ty Eio.Resource.t); - close_input; - output = - Eio.Buf_read.of_flow - ~max_size:(Strategy_protocol.max_message_bytes + 1) - child_stdout; - await_process = (fun () -> Eio.Process.await process); - clock = Eio.Stdenv.clock env; - transcript; - timeout; - next_sequence = 1L; - } - in - let* identity = initialize session initialization in - let* value = use session in - let* () = shutdown session in - let* () = await_exit session in - Ok (value, identity) - in - match result with - | Error _ as error -> fail error - | Ok value -> ( - match Strategy_transcript.commit transcript with - | Ok () -> Ok value - | Error _ as error -> error) - with exception_ -> - fail - (Error - (exception_message "external strategy process" exception_)))) + Error + (diagnostic ~code:Diagnostic.Strategy_invalid_configuration + "external strategy executable must not be empty") + | executable :: _ -> + let message = + Strategy_protocol.initialize_message ~sequence:1L initialization + |> Strategy_protocol.message_to_string + in + let observed = String.length message in + if observed > Strategy_protocol.max_message_bytes then + Error + (diagnostic ~sequence:1L ~code:Diagnostic.Resource_limit + (Printf.sprintf + "strategy initialization message is %d bytes; limit is %d \ + bytes" + observed Strategy_protocol.max_message_bytes)) + else Ok executable + +let validate_configuration ~command ~timeout ~initialization = + configured_executable ~command ~timeout ~initialization |> Result.map ignore + +let run_session ~effects ~env ~command ~executable ~timeout ~transcript + ~(initialization : Strategy_protocol.initialization) use = + try + Eio.Switch.run ~name:"external-strategy" @@ fun switch -> + let process_manager = Eio.Stdenv.process_mgr env in + if enable_child_subreaper () <> 0 then + failwith "could not enable external strategy child reaping"; + let child_stdout, strategy_stdout = Eio_unix.pipe switch in + let strategy_stdin, child_stdin = Eio_unix.pipe switch in + let fds = + [ + (0, Eio_unix.Resource.fd strategy_stdin, `Blocking); + (1, Eio_unix.Resource.fd strategy_stdout, `Blocking); + (2, Eio_unix.Resource.fd (Eio.Stdenv.stderr env), `Blocking); + ] + in + let process = + Boundary_effects.perform effects Boundary_effects.Spawn_process (fun () -> + Eio_unix.Process.spawn_unix ~sw:switch process_manager ~pgid:0 ~fds + ~executable command) + in + Eio.Flow.close strategy_stdin; + Eio.Flow.close strategy_stdout; + let child = + { + process; + pgid = Eio.Process.pid process; + clock = Eio.Stdenv.clock env; + effects; + status = None; + } + in + let close_input () = Eio.Flow.close child_stdin in + let session = + { + input = (child_stdin :> Eio.Flow.sink_ty Eio.Resource.t); + close_input; + output = + Eio.Buf_read.of_flow + ~max_size:(Strategy_protocol.max_message_bytes + 1) + child_stdout; + child; + clock = Eio.Stdenv.clock env; + transcript; + effects; + timeout; + next_sequence = 1L; + } + in + try + let result = + let* identity = initialize session initialization in + let* value = use session in + let* () = shutdown session in + let* () = await_exit session in + Ok (value, identity) + in + match result with + | Ok _ -> result + | Error _ -> append_cleanup_error result child + with exception_ -> + let backtrace = Printexc.get_raw_backtrace () in + ignore (terminate_process_group child); + Printexc.raise_with_backtrace exception_ backtrace + with + | Eio.Cancel.Cancelled _ as exception_ -> raise exception_ + | exception_ -> + Error (exception_diagnostic "external strategy process" exception_) + +let with_staged_session ?(effects = Boundary_effects.direct) ~env ~command + ~timeout ~transcript ~initialization use = + match configured_executable ~command ~timeout ~initialization with + | Error _ as error -> error + | Ok executable -> + run_session ~effects ~env ~command ~executable ~timeout ~transcript + ~initialization use + +let with_session ?(effects = Boundary_effects.direct) + ?(durability = Artifact_writer.Buffered) ~env ~command ~timeout + ~transcript_path ~initialization use = + match configured_executable ~command ~timeout ~initialization with + | Error _ as error -> error + | Ok executable -> ( + match Strategy_transcript.create ~effects ~durability transcript_path with + | Error _ as error -> error + | Ok transcript -> ( + let fail result = + Strategy_transcript.close_preserving_partial transcript; + result + in + try + match + run_session ~effects ~env ~command ~executable ~timeout + ~transcript ~initialization use + with + | Error _ as error -> fail error + | Ok value -> ( + match Strategy_transcript.commit transcript with + | Ok () -> Ok value + | Error _ as error -> error) + with Eio.Cancel.Cancelled _ as exception_ -> + Strategy_transcript.close_preserving_partial transcript; + raise exception_)) diff --git a/lib/strategy_process.mli b/lib/strategy_process.mli index 29ef2c0..c409bf9 100644 --- a/lib/strategy_process.mli +++ b/lib/strategy_process.mli @@ -2,17 +2,35 @@ type t +val validate_configuration : + command:string list -> + timeout:float -> + initialization:Strategy_protocol.initialization -> + (unit, Diagnostic.t) result + +val with_staged_session : + ?effects:Boundary_effects.t -> + env:Eio_unix.Stdenv.base -> + command:string list -> + timeout:float -> + transcript:Strategy_transcript.t -> + initialization:Strategy_protocol.initialization -> + (t -> ('a, Diagnostic.t) result) -> + ('a * Strategy_protocol.identity, Diagnostic.t) result + val with_session : + ?effects:Boundary_effects.t -> + ?durability:Artifact_writer.durability -> env:Eio_unix.Stdenv.base -> command:string list -> timeout:float -> transcript_path:string -> initialization:Strategy_protocol.initialization -> - (t -> ('a, string) result) -> - ('a * Strategy_protocol.identity, string) result + (t -> ('a, Diagnostic.t) result) -> + ('a * Strategy_protocol.identity, Diagnostic.t) result val on_event : t -> Strategy.context -> Strategy.event -> - (Strategy.intent list, string) result + (Strategy.intent list, Diagnostic.t) result diff --git a/lib/strategy_protocol.ml b/lib/strategy_protocol.ml index 6bade90..e08afdd 100644 --- a/lib/strategy_protocol.ml +++ b/lib/strategy_protocol.ml @@ -1,5 +1,5 @@ let version = Contract.strategy_protocol_version -let max_message_bytes = 1_048_576 +let max_message_bytes = Resource_limits.strategy_message_bytes type initialization = { scenario_contract_version : string; @@ -7,11 +7,14 @@ type initialization = { metadata : Yojson.Safe.t; run_id : Id.Run.t; base_currency : string; - initial_cash : (string * Scalar.Money.t) list; + initial_portfolio : Initial_portfolio.t; instruments : Instrument.t list; + venue_calendars : Venue_calendar.t list; risk : Risk.t; execution_model : Execution_model.t; execution : Execution.t; + financing : Financing.policy; + settlement : Settlement.policy; } type identity = { name : Id.Strategy.t; version : string option } @@ -59,26 +62,224 @@ let instrument_to_yojson instrument = ("lot_size", quantity instrument.lot_size); ] +let phase_to_yojson (phase : Venue_calendar.phase) = + `Assoc + [ + ("phase", string (Venue_calendar.phase_kind_to_string phase.kind)); + ("opens_at", timestamp phase.opens_at); + ("closes_at", timestamp phase.closes_at); + ] + +let session_to_yojson (session : Venue_calendar.session) = + `Assoc + [ + ("session_date", string session.session_date); + ("policy", string (Venue_calendar.session_kind_to_string session.kind)); + ("phases", `List (List.map phase_to_yojson session.phases)); + ] + +let venue_calendar_to_yojson (calendar : Venue_calendar.t) = + `Assoc + [ + ("calendar_id", string (Id.Venue_calendar.to_string calendar.id)); + ("calendar_version", string calendar.version); + ("venue_id", string (Id.Venue.to_string calendar.venue_id)); + ( "instrument_ids", + `List + (calendar.instrument_ids |> Id.Instrument.Set.elements + |> List.map instrument_id) ); + ("sessions", `List (List.map session_to_yojson calendar.sessions)); + ] + +let group_kind_to_string = function + | Risk.Issuer -> "issuer" + | Risk.Sector -> "sector" + | Risk.Currency -> "currency" + | Risk.Country -> "country" + | Risk.Asset_class -> "asset_class" + | Risk.Custom -> "custom" + +let nullable render = Option.fold ~none:`Null ~some:render + +let financing_to_yojson policy = + `Assoc + [ + ( "day_count", + string (Financing.day_count_to_string policy.Financing.day_count) ); + ( "compounding", + string (Financing.compounding_to_string policy.compounding) ); + ( "borrow_missing_data", + string (Financing.missing_data_to_string policy.borrow_missing_data) ); + ( "cash_missing_data", + string (Financing.missing_data_to_string policy.cash_missing_data) ); + ( "locate_policy", + string (Financing.locate_policy_to_string policy.locate_policy) ); + ( "recall_policy", + string (Financing.recall_policy_to_string policy.recall_policy) ); + ] + +let settlement_to_yojson (policy : Settlement.policy) = + let calendar (calendar : Settlement.calendar) = + `Assoc + [ + ("calendar_id", string calendar.calendar_id); + ("version", string calendar.version); + ("business_dates", `List (List.map string calendar.business_dates)); + ] + in + let rule (rule : Settlement.rule) = + `Assoc + [ + ("instrument_id", instrument_id rule.instrument_id); + ("calendar_id", string rule.calendar_id); + ("lag_business_days", `Int rule.lag_business_days); + ] + in + `Assoc + [ + ( "cash_buying_power", + string (Settlement.cash_buying_power_to_string policy.cash_buying_power) + ); + ( "position_availability", + string + (Settlement.position_availability_to_string + policy.position_availability) ); + ("calendars", `List (List.map calendar policy.calendars)); + ("rules", `List (List.map rule policy.rules)); + ] + +let instrument_policy_to_yojson (policy : Risk.instrument_policy) = + `Assoc + [ + ("instrument_id", instrument_id policy.instrument_id); + ("max_order_quantity", quantity policy.max_order_quantity); + ("max_long_position", quantity policy.max_long_position); + ("max_short_position", quantity policy.max_short_position); + ("max_notional_exposure", nullable money policy.max_notional_exposure); + ("initial_margin_bps", `Int policy.initial_margin_bps); + ("maintenance_margin_bps", `Int policy.maintenance_margin_bps); + ("shorting_allowed", `Bool policy.shorting_allowed); + ] + +let group_to_yojson (group : Risk.group) = + let limits = group.limits in + `Assoc + [ + ("group_id", string (Id.Risk_group.to_string group.group_id)); + ("group_version", string "1"); + ("group_type", string (group_kind_to_string group.group_kind)); + ("instrument_ids", `List (List.map instrument_id group.instrument_ids)); + ( "limits", + `Assoc + [ + ("max_gross_exposure", nullable money limits.max_gross_exposure); + ("max_long_exposure", nullable money limits.max_long_exposure); + ("max_short_exposure", nullable money limits.max_short_exposure); + ( "max_absolute_net_exposure", + nullable money limits.max_absolute_net_exposure ); + ("max_concentration", nullable ratio limits.max_concentration); + ] ); + ] + let risk_to_yojson risk = `Assoc [ - ("max_order_quantity", quantity (Risk.max_order_quantity risk)); - ("max_long_position", quantity (Risk.max_long_position risk)); - ("max_short_position", quantity (Risk.max_short_position risk)); ("max_gross_exposure", money (Risk.max_gross_exposure risk)); ("max_leverage", ratio (Risk.max_leverage risk)); - ("initial_margin_bps", `Int (Risk.initial_margin_bps risk)); - ("maintenance_margin_bps", `Int (Risk.maintenance_margin_bps risk)); - ("short_borrow_bps", `Int (Risk.short_borrow_bps risk)); + ( "instrument_policies", + `List + (List.map instrument_policy_to_yojson (Risk.instrument_policies risk)) + ); + ("groups", `List (List.map group_to_yojson (Risk.groups risk))); ] let execution_to_yojson model execution = + let fee_component_to_yojson component = + let value = + match Fee_schedule.component_basis component with + | Fee_schedule.Fixed value | Fee_schedule.Per_unit value -> money value + | Fee_schedule.Notional_bps value -> `Int value + in + `Assoc + [ + ("name", string (Fee_schedule.component_name component)); + ("currency", string (Fee_schedule.component_currency component)); + ( "kind", + string + (Fee_schedule.basis_kind (Fee_schedule.component_basis component)) + ); + ("value", value); + ( "rounding", + string + (Fee_schedule.rounding_to_string + (Fee_schedule.component_rounding component)) ); + ( "applies_to", + string + (Fee_schedule.applicability_to_string + (Fee_schedule.component_applicability component)) ); + ] + in + let fee_schedule_to_yojson schedule = + `Assoc + [ + ("schedule_id", string (Fee_schedule.schedule_id schedule)); + ("instrument_id", instrument_id (Fee_schedule.instrument_id schedule)); + ( "settlement_currency", + string (Fee_schedule.settlement_currency schedule) ); + ("minimum", nullable money (Fee_schedule.minimum schedule)); + ("maximum", nullable money (Fee_schedule.maximum schedule)); + ( "components", + `List + (List.map fee_component_to_yojson + (Fee_schedule.components schedule)) ); + ] + in + let configuration = + [ + ("version", string "1"); + ("participation_bps", `Int (Execution.participation_bps execution)); + ( "fee_schedules", + `List + (List.map fee_schedule_to_yojson (Execution.fee_schedules execution)) + ); + ] + in + let configuration = + match Execution_model.name model with + | "completed_bar_next_open_v1" | "completed_bar_adverse_touch_v1" -> + let costs = Execution.cost_model execution |> Option.get in + configuration + @ [ + ( "spread_model", + `Assoc + [ + ("model", string "fixed_half_spread_v1"); + ("half_spread_bps", `Int costs.half_spread_bps); + ] ); + ( "impact_model", + `Assoc + [ + ("model", string "linear_participation_v1"); + ("coefficient_bps", `Int costs.impact_coefficient_bps); + ( "missing_volume_policy", + string + (match costs.missing_volume_policy with + | Execution.Reject_missing_volume -> "reject" + | Zero_impact -> "zero_impact") ); + ] ); + ] + | "order_book_v1" -> + configuration + @ [ + ( "max_depth_levels", + `Int (Option.get (Execution.book_depth_limit execution)) ); + ] + | _ -> configuration + in `Assoc [ ("model", string (Execution_model.name model)); - ("participation_bps", `Int (Execution.participation_bps execution)); - ("fixed_fee", money (Execution.fixed_fee execution)); - ("fee_bps", `Int (Execution.fee_bps execution)); + ("configuration", `Assoc configuration); ] let initialize_message ~sequence:message_sequence initialization = @@ -88,28 +289,35 @@ let initialize_message ~sequence:message_sequence initialization = Id.Instrument.compare left.Instrument.id right.Instrument.id) initialization.instruments in - let initial_cash = + let venue_calendars = List.sort - (fun (left, _) (right, _) -> String.compare left right) - initialization.initial_cash + (fun left right -> + Id.Venue_calendar.compare left.Venue_calendar.id right.Venue_calendar.id) + initialization.venue_calendars in - message ~sequence:message_sequence ~message_type:"initialize" - (`Assoc - [ - ("engine_version", string Contract.engine_version); - ( "scenario_contract_version", - string initialization.scenario_contract_version ); - ("scenario_sha256", string initialization.scenario_sha256); - ("run_id", string (Id.Run.to_string initialization.run_id)); - ("base_currency", string initialization.base_currency); - ("initial_cash", `List (List.map cash_balance_to_yojson initial_cash)); - ("instruments", `List (List.map instrument_to_yojson instruments)); - ("risk", risk_to_yojson initialization.risk); - ( "execution", - execution_to_yojson initialization.execution_model - initialization.execution ); - ("metadata", initialization.metadata); - ]) + let fields = + [ + ("engine_version", string Contract.engine_version); + ( "scenario_contract_version", + string initialization.scenario_contract_version ); + ("scenario_sha256", string initialization.scenario_sha256); + ("run_id", string (Id.Run.to_string initialization.run_id)); + ("base_currency", string initialization.base_currency); + ( "initial_portfolio", + Codec.initial_portfolio_to_yojson initialization.initial_portfolio ); + ( "venue_calendars", + `List (List.map venue_calendar_to_yojson venue_calendars) ); + ("financing", financing_to_yojson initialization.financing); + ("settlement", settlement_to_yojson initialization.settlement); + ("instruments", `List (List.map instrument_to_yojson instruments)); + ("risk", risk_to_yojson initialization.risk); + ( "execution", + execution_to_yojson initialization.execution_model + initialization.execution ); + ("metadata", initialization.metadata); + ] + in + message ~sequence:message_sequence ~message_type:"initialize" (`Assoc fields) let cash_attribution_to_yojson (balance : Account.cash_attribution) = `Assoc @@ -118,6 +326,12 @@ let cash_attribution_to_yojson (balance : Account.cash_attribution) = ("amount", money balance.amount); ("fx_rate", price balance.fx_rate); ("base_value", money balance.base_value); + ("interest", money balance.interest); + ("base_interest", money balance.base_interest); + ("settled_amount", money balance.settled_amount); + ("unsettled_amount", money balance.unsettled_amount); + ("base_settled_value", money balance.base_settled_value); + ("base_unsettled_value", money balance.base_unsettled_value); ] let marked_position_to_yojson (position : Strategy.marked_position) = @@ -128,6 +342,20 @@ let marked_position_to_yojson (position : Strategy.marked_position) = ("mark", price position.mark); ("base_market_value", money position.base_market_value); ("weight", Option.fold ~none:`Null ~some:weight position.weight); + ("settled_quantity", quantity position.settled_quantity); + ("unsettled_quantity", quantity position.unsettled_quantity); + ] + +let group_exposure_to_yojson (exposure : Risk.group_exposure) = + `Assoc + [ + ("group_id", string (Id.Risk_group.to_string exposure.group_id)); + ("gross_exposure", money exposure.gross_exposure); + ("net_exposure", money exposure.net_exposure); + ("long_exposure", money exposure.long_exposure); + ("short_exposure", money exposure.short_exposure); + ( "concentration", + Option.fold ~none:`Null ~some:weight exposure.concentration ); ] let context_to_yojson context = @@ -156,26 +384,28 @@ let context_to_yojson context = positions |> List.filter_map Fun.id in + let portfolio_fields = + [ + ("base_currency", string portfolio.base_currency); + ("cash", money portfolio.cash); + ("net_market_value", money portfolio.net_market_value); + ("long_market_value", money portfolio.long_market_value); + ("short_market_value", money portfolio.short_market_value); + ("gross_exposure", money portfolio.gross_exposure); + ("equity", money portfolio.equity); + ("weights_available", `Bool (Option.is_some portfolio.cash_weight)); + ("cash_weight", Option.fold ~none:`Null ~some:weight portfolio.cash_weight); + ( "cash_balances", + `List (List.map cash_attribution_to_yojson cash_balances) ); + ("positions", `List (List.map marked_position_to_yojson positions)); + ( "group_exposures", + `List (List.map group_exposure_to_yojson portfolio.group_exposures) ); + ] + in `Assoc [ ("now", timestamp (Strategy.now context)); - ( "portfolio", - `Assoc - [ - ("base_currency", string portfolio.base_currency); - ("cash", money portfolio.cash); - ("net_market_value", money portfolio.net_market_value); - ("long_market_value", money portfolio.long_market_value); - ("short_market_value", money portfolio.short_market_value); - ("gross_exposure", money portfolio.gross_exposure); - ("equity", money portfolio.equity); - ("weights_available", `Bool (Option.is_some portfolio.cash_weight)); - ( "cash_weight", - Option.fold ~none:`Null ~some:weight portfolio.cash_weight ); - ( "cash_balances", - `List (List.map cash_attribution_to_yojson cash_balances) ); - ("positions", `List (List.map marked_position_to_yojson positions)); - ] ); + ("portfolio", `Assoc portfolio_fields); ("working_orders", `List (List.map Codec.order_to_yojson working_orders)); ("latest_bars", `List (List.map Codec.bar_to_yojson latest_bars)); ] @@ -208,8 +438,8 @@ let event_message ~sequence:message_sequence context event = ("context", context_to_yojson context); ("event", event_to_yojson event); ]) -let shutdown_message ~sequence:message_sequence = - message ~sequence:message_sequence ~message_type:"shutdown" (`Assoc []) +let shutdown_message ~sequence = + message ~sequence ~message_type:"shutdown" (`Assoc []) let object_fields ~name ~expected = function | `Assoc fields -> @@ -265,11 +495,18 @@ let parse_intents_payload json = in let* intents_json = field fields "intents" in match intents_json with + | `List values when List.length values > Resource_limits.intents_per_batch -> + Error + (Printf.sprintf "intent count is %d; limit is %d" (List.length values) + Resource_limits.intents_per_batch) | `List values -> List.fold_left (fun result value -> let* intents = result in - let* intent = Scenario.intent_of_yojson value in + let* intent = + Scenario.intent_of_yojson value + |> Result.map_error Diagnostic.to_human + in Ok (intent :: intents)) (Ok []) values |> Result.map (fun values -> Intents (List.rev values)) @@ -279,7 +516,7 @@ let parse_stopped_payload json = let* _ = object_fields ~name:"strategy stopped payload" ~expected:[] json in Ok Stopped -let response_of_yojson ~expected_sequence json = +let response_of_yojson_result ~expected_sequence json = let* fields = object_fields ~name:"strategy response" ~expected: @@ -322,16 +559,67 @@ let response_of_yojson ~expected_sequence json = |> Result.map (fun message -> Failed message) | value -> Error ("unsupported strategy response type: " ^ value) +let response_of_yojson ~expected_sequence json = + let json_path = + match json with + | `Assoc fields -> ( + match List.assoc_opt "strategy_protocol_version" fields with + | Some (`String supplied) when not (String.equal supplied version) -> + "$.strategy_protocol_version" + | _ -> ( + match List.assoc_opt "strategy_sequence" fields with + | Some (`String supplied) + when not + (String.equal supplied (Int64.to_string expected_sequence)) + -> + "$.strategy_sequence" + | _ -> "$")) + | _ -> "$" + in + let intent_count = + match json with + | `Assoc fields -> ( + match List.assoc_opt "payload" fields with + | Some (`Assoc payload_fields) -> ( + match List.assoc_opt "intents" payload_fields with + | Some (`List values) -> Some (List.length values) + | _ -> None) + | _ -> None) + | _ -> None + in + match intent_count with + | Some observed when observed > Resource_limits.intents_per_batch -> + Error + (Diagnostic.make ~code:Diagnostic.Resource_limit + ~phase:Diagnostic.Strategy ~sequence:expected_sequence + ~json_path:"$.payload.intents" + (Printf.sprintf "intent count is %d; limit is %d" observed + Resource_limits.intents_per_batch)) + | _ -> + response_of_yojson_result ~expected_sequence json + |> Result.map_error (fun message -> + Diagnostic.make ~code:Diagnostic.Strategy_protocol + ~phase:Diagnostic.Strategy ~sequence:expected_sequence ~json_path + message) + let response_of_string ~expected_sequence document = if String.length document > max_message_bytes then - Error "strategy response exceeds the maximum message size" + Error + (Diagnostic.make ~code:Diagnostic.Resource_limit + ~phase:Diagnostic.Strategy ~sequence:expected_sequence + (Printf.sprintf "strategy message is %d bytes; limit is %d bytes" + (String.length document) max_message_bytes)) else try let json = Yojson.Safe.from_string document in response_of_yojson ~expected_sequence json |> Result.map (fun response -> (response, json)) - with Yojson.Json_error message -> - Error ("invalid strategy response JSON: " ^ message) + with Yojson.Json_error message as exception_ -> + Error + (Diagnostic.of_exception ~code:Diagnostic.Strategy_protocol + ~phase:Diagnostic.Strategy ~sequence:expected_sequence ~json_path:"$" + ~message:("invalid strategy response JSON: " ^ message) + exception_) let direction_to_string = function | Engine_to_strategy -> "engine_to_strategy" diff --git a/lib/strategy_protocol.mli b/lib/strategy_protocol.mli index 1f323cf..54cc26f 100644 --- a/lib/strategy_protocol.mli +++ b/lib/strategy_protocol.mli @@ -9,11 +9,14 @@ type initialization = { metadata : Yojson.Safe.t; run_id : Id.Run.t; base_currency : string; - initial_cash : (string * Scalar.Money.t) list; + initial_portfolio : Initial_portfolio.t; instruments : Instrument.t list; + venue_calendars : Venue_calendar.t list; risk : Risk.t; execution_model : Execution_model.t; execution : Execution.t; + financing : Financing.policy; + settlement : Settlement.policy; } type identity = private { name : Id.Strategy.t; version : string option } @@ -34,10 +37,12 @@ val event_message : val shutdown_message : sequence:int64 -> Yojson.Safe.t val response_of_yojson : - expected_sequence:int64 -> Yojson.Safe.t -> (response, string) result + expected_sequence:int64 -> Yojson.Safe.t -> (response, Diagnostic.t) result val response_of_string : - expected_sequence:int64 -> string -> (response * Yojson.Safe.t, string) result + expected_sequence:int64 -> + string -> + (response * Yojson.Safe.t, Diagnostic.t) result val transcript_record : transcript_sequence:int64 -> diff --git a/lib/strategy_transcript.ml b/lib/strategy_transcript.ml index 1ac60f4..f6911bb 100644 --- a/lib/strategy_transcript.ml +++ b/lib/strategy_transcript.ml @@ -1,78 +1,68 @@ -type t = { - final_path : string; - partial_path : string; - channel : out_channel; - mutable next_sequence : int64; - mutable closed : bool; -} +type t = { artifact : Artifact_writer.t; mutable next_sequence : int64 } -let create final_path = - let partial_path = final_path ^ ".partial" in - if Sys.file_exists final_path then - Error ("strategy transcript already exists: " ^ final_path) - else if Sys.file_exists partial_path then - Error ("partial strategy transcript already exists: " ^ partial_path) +let diagnostic_version = "1" +let max_rejection_prefix_bytes = 256 + +let diagnostic ?sequence ~code message = + Diagnostic.make ?sequence ~code ~phase:Diagnostic.Artifact message + +let create ?effects ?durability final_path = + Artifact_writer.create ?effects ?durability ~label:"strategy transcript" + final_path + |> Result.map (fun artifact -> { artifact; next_sequence = 1L }) + +let append_record transcript record = + if Int64.equal transcript.next_sequence Int64.max_int then + Error + (diagnostic ~sequence:transcript.next_sequence + ~code:Diagnostic.Artifact_state + "strategy transcript sequence is exhausted") else - try - let channel = - open_out_gen - [ Open_wronly; Open_creat; Open_excl; Open_binary ] - 0o600 partial_path - in - Ok - { - final_path; - partial_path; - channel; - next_sequence = 1L; - closed = false; - } - with Sys_error message -> - Error ("could not create strategy transcript: " ^ message) + Artifact_writer.append transcript.artifact + (Yojson.Safe.to_string record ^ "\n") + |> Result.map (fun () -> + transcript.next_sequence <- Int64.succ transcript.next_sequence) + |> Result.map_error (Diagnostic.annotate ~sequence:transcript.next_sequence) let append transcript ~direction message = - if transcript.closed then - Error "cannot append to a closed strategy transcript" - else if Int64.equal transcript.next_sequence Int64.max_int then - Error "strategy transcript sequence is exhausted" - else - try - let record = - Strategy_protocol.transcript_record - ~transcript_sequence:transcript.next_sequence ~direction ~message - in - output_string transcript.channel - (Strategy_protocol.message_to_string record); - output_char transcript.channel '\n'; - flush transcript.channel; - transcript.next_sequence <- Int64.succ transcript.next_sequence; - Ok () - with Sys_error message -> - Error - ("could not append strategy transcript " ^ transcript.partial_path - ^ ": " ^ message) + Strategy_protocol.transcript_record + ~transcript_sequence:transcript.next_sequence ~direction ~message + |> append_record transcript + +let hex_of_string value = + let digits = "0123456789abcdef" in + String.init + (String.length value * 2) + (fun index -> + let byte = Char.code value.[index / 2] in + if index mod 2 = 0 then digits.[byte lsr 4] else digits.[byte land 0xf]) + +let append_rejection transcript ~expected_sequence ~diagnostic:rejection + ~raw_prefix ~observed_bytes ~truncated = + let raw_prefix = + if String.length raw_prefix <= max_rejection_prefix_bytes then raw_prefix + else String.sub raw_prefix 0 max_rejection_prefix_bytes + in + `Assoc + [ + ("strategy_diagnostic_version", `String diagnostic_version); + ("transcript_sequence", `String (Int64.to_string transcript.next_sequence)); + ("record_type", `String "rejected_strategy_response"); + ("expected_strategy_sequence", `String (Int64.to_string expected_sequence)); + ("diagnostic", Diagnostic.to_yojson rejection); + ( "evidence", + `Assoc + [ + ("encoding", `String "hex"); + ("prefix", `String (hex_of_string raw_prefix)); + ("observed_bytes", `Int observed_bytes); + ("truncated", `Bool truncated); + ] ); + ] + |> append_record transcript let close_preserving_partial transcript = - if not transcript.closed then ( - transcript.closed <- true; - close_out_noerr transcript.channel) + Artifact_writer.close_preserving_partial transcript.artifact -let commit transcript = - if transcript.closed then Error "cannot commit a closed strategy transcript" - else - try - flush transcript.channel; - close_out transcript.channel; - transcript.closed <- true; - Unix.link transcript.partial_path transcript.final_path; - Unix.unlink transcript.partial_path; - Ok () - with - | Sys_error message -> - close_preserving_partial transcript; - Error ("could not finalize strategy transcript: " ^ message) - | Unix.Unix_error (code, operation, target) -> - close_preserving_partial transcript; - Error - (Printf.sprintf "could not finalize strategy transcript: %s(%s): %s" - operation target (Unix.error_message code)) +let commit transcript = Artifact_writer.commit [ transcript.artifact ] +let artifact transcript = transcript.artifact diff --git a/lib/strategy_transcript.mli b/lib/strategy_transcript.mli index 9b6b5c4..8fcae4f 100644 --- a/lib/strategy_transcript.mli +++ b/lib/strategy_transcript.mli @@ -2,13 +2,29 @@ type t -val create : string -> (t, string) result +val max_rejection_prefix_bytes : int + +val create : + ?effects:Boundary_effects.t -> + ?durability:Artifact_writer.durability -> + string -> + (t, Diagnostic.t) result val append : t -> direction:Strategy_protocol.direction -> Yojson.Safe.t -> - (unit, string) result + (unit, Diagnostic.t) result + +val append_rejection : + t -> + expected_sequence:int64 -> + diagnostic:Diagnostic.t -> + raw_prefix:string -> + observed_bytes:int -> + truncated:bool -> + (unit, Diagnostic.t) result val close_preserving_partial : t -> unit -val commit : t -> (unit, string) result +val commit : t -> (unit, Diagnostic.t) result +val artifact : t -> Artifact_writer.t diff --git a/lib/venue_calendar.ml b/lib/venue_calendar.ml new file mode 100644 index 0000000..4992c3d --- /dev/null +++ b/lib/venue_calendar.ml @@ -0,0 +1,140 @@ +type phase_kind = + | Premarket + | Opening_auction + | Regular + | Closing_auction + | Postmarket + +type phase = { kind : phase_kind; opens_at : Ptime.t; closes_at : Ptime.t } +type session_kind = Regular_session | Early_close | Holiday + +type session = { + session_date : string; + kind : session_kind; + phases : phase list; +} + +type t = { + id : Id.Venue_calendar.t; + version : string; + venue_id : Id.Venue.t; + instrument_ids : Id.Instrument.Set.t; + sessions : session list; +} + +let ( let* ) result function_ = + match result with Ok value -> function_ value | Error _ as error -> error + +let phase_kind_of_string = function + | "premarket" -> Ok Premarket + | "opening_auction" -> Ok Opening_auction + | "regular" -> Ok Regular + | "closing_auction" -> Ok Closing_auction + | "postmarket" -> Ok Postmarket + | value -> Error (Printf.sprintf "unsupported venue phase %S" value) + +let phase_kind_to_string = function + | Premarket -> "premarket" + | Opening_auction -> "opening_auction" + | Regular -> "regular" + | Closing_auction -> "closing_auction" + | Postmarket -> "postmarket" + +let session_kind_of_string = function + | "regular" -> Ok Regular_session + | "early_close" -> Ok Early_close + | "holiday" -> Ok Holiday + | value -> Error (Printf.sprintf "unsupported session policy %S" value) + +let session_kind_to_string = function + | Regular_session -> "regular" + | Early_close -> "early_close" + | Holiday -> "holiday" + +let phase_rank = function + | Premarket -> 0 + | Opening_auction -> 1 + | Regular -> 2 + | Closing_auction -> 3 + | Postmarket -> 4 + +let create_phase ~kind ~opens_at ~closes_at = + if Ptime.compare opens_at closes_at >= 0 then + Error "venue phase opens_at must precede closes_at" + else Ok { kind; opens_at; closes_at } + +let valid_session_date value = + String.length value = 10 + && value.[4] = '-' + && value.[7] = '-' + && + match Ptime.of_rfc3339 (value ^ "T00:00:00Z") with + | Ok _ -> true + | Error _ -> false + +let validate_phases phases = + let rec loop previous_kind previous_close seen_regular = function + | [] -> + if seen_regular then Ok () + else Error "open session must define a regular phase" + | (phase : phase) :: remaining -> + if + Option.exists + (fun kind -> phase_rank phase.kind <= phase_rank kind) + previous_kind + then Error "venue phases must be unique and in market order" + else if + Option.exists + (fun closes_at -> Ptime.compare phase.opens_at closes_at < 0) + previous_close + then Error "venue phases must not overlap" + else + loop (Some phase.kind) (Some phase.closes_at) + (seen_regular || phase.kind = Regular) + remaining + in + loop None None false phases + +let create_session ~session_date ~kind ~phases = + if not (valid_session_date session_date) then + Error "session_date must be a canonical YYYY-MM-DD date" + else + match kind with + | Holiday -> + if phases = [] then Ok { session_date; kind; phases } + else Error "holiday session policy must not define phases" + | Regular_session | Early_close -> + let* () = validate_phases phases in + Ok { session_date; kind; phases } + +let create ~id ~version ~venue_id ~instrument_ids ~sessions = + if not (String.equal version "1") then + Error (Printf.sprintf "unsupported venue calendar version %S" version) + else if instrument_ids = [] then + Error "venue calendar must reference at least one instrument" + else if sessions = [] then + Error "venue calendar must define at least one session policy" + else + let instrument_set = Id.Instrument.Set.of_list instrument_ids in + if Id.Instrument.Set.cardinal instrument_set <> List.length instrument_ids + then Error "venue calendar instrument IDs must be unique" + else + let dates = List.map (fun session -> session.session_date) sessions in + if List.sort_uniq String.compare dates <> dates then + Error "venue calendar sessions must have unique, increasing dates" + else + Ok { id; version; venue_id; instrument_ids = instrument_set; sessions } + +let session_on calendar ~session_date = + match + List.find_opt + (fun session -> String.equal session.session_date session_date) + calendar.sessions + with + | Some session -> Ok session + | None -> + Error + (Printf.sprintf + "venue calendar %s version %s has no explicit policy for %s" + (Id.Venue_calendar.to_string calendar.id) + calendar.version session_date) diff --git a/lib/venue_calendar.mli b/lib/venue_calendar.mli new file mode 100644 index 0000000..9b9e277 --- /dev/null +++ b/lib/venue_calendar.mli @@ -0,0 +1,59 @@ +(** Immutable venue-local session policies resolved outside the reducer. *) + +type phase_kind = + | Premarket + | Opening_auction + | Regular + | Closing_auction + | Postmarket + +type phase = private { + kind : phase_kind; + opens_at : Ptime.t; + closes_at : Ptime.t; +} + +type session_kind = Regular_session | Early_close | Holiday + +type session = private { + session_date : string; + kind : session_kind; + phases : phase list; +} + +type t = private { + id : Id.Venue_calendar.t; + version : string; + venue_id : Id.Venue.t; + instrument_ids : Id.Instrument.Set.t; + sessions : session list; +} + +val phase_kind_of_string : string -> (phase_kind, string) result +val phase_kind_to_string : phase_kind -> string +val session_kind_of_string : string -> (session_kind, string) result +val session_kind_to_string : session_kind -> string + +val create_phase : + kind:phase_kind -> + opens_at:Ptime.t -> + closes_at:Ptime.t -> + (phase, string) result + +val create_session : + session_date:string -> + kind:session_kind -> + phases:phase list -> + (session, string) result + +val create : + id:Id.Venue_calendar.t -> + version:string -> + venue_id:Id.Venue.t -> + instrument_ids:Id.Instrument.t list -> + sessions:session list -> + (t, string) result + +val session_on : t -> session_date:string -> (session, string) result +(** Return the explicit policy for [session_date]. Missing dates are errors and + are never inferred from weekdays, holidays, or neighboring sessions. *) diff --git a/lychee.toml b/lychee.toml new file mode 100644 index 0000000..b4524da --- /dev/null +++ b/lychee.toml @@ -0,0 +1,21 @@ +# External links are network-sensitive and run only in the External links workflow. +verbose = "info" +format = "detailed" +no_progress = true + +threads = 2 +max_concurrency = 4 +host_concurrency = 2 +max_redirects = 5 +max_retries = 2 +timeout = 20 +retry_wait_time = 2 + +scheme = ["https"] +require_https = true +insecure = false +exclude_all_private = true +include_mail = false + +# Exceptions must match exact reviewed URLs and explain why the target cannot be checked. +exclude = [] diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..a5b8e0f --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,61 @@ +site_name: Trading Engine +site_description: Deterministic OCaml trading replay engine +site_url: https://fallblu.github.io/trading-engine/ +repo_url: https://github.com/fallblu/trading-engine +repo_name: fallblu/trading-engine +edit_uri: edit/develop/ +docs_dir: _build/documentation-source +site_dir: site +strict: true +theme: + name: material + features: + - content.code.copy + - navigation.footer + - navigation.sections + - navigation.top + - search.highlight +nav: + - Home: index.md + - Project: + - Architecture: docs/architecture.md + - Execution model: docs/execution-model.md + - Scenario and journal: docs/scenario.md + - Diagnostics: docs/diagnostics.md + - Persistra integration: docs/persistra.md + - Security policy: SECURITY.md + - Contracts: + - Conformance corpus: contracts/conformance/README.md + - CLI results: + - Current v1: contracts/cli/v1/README.md + - Diagnostics: + - Current v1: contracts/diagnostic/v1/README.md + - Scenario and journal: + - Current v1: contracts/v1/README.md + - External strategy: + - Current v1: contracts/strategy/v1/README.md + - API reference: docs/api-reference.md + - Engineering: + - Continuous integration: docs/continuous-integration.md + - OCaml coverage: docs/coverage.md + - Performance: docs/performance.md + - Reducer property testing: docs/reducer-property-testing.md + - Protocol fuzzing: docs/fuzzing.md + - Documentation platform: docs/documentation-platform.md + - Release artifacts: docs/release-artifacts.md + - Security maintenance: docs/security-maintenance.md + - Repository governance: docs/repository-governance.md + - Contributing: CONTRIBUTING.md + - Support: SUPPORT.md + - Changelog: CHANGELOG.md +plugins: + - search +markdown_extensions: + - admonition + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.superfences + - tables + - toc: + permalink: true diff --git a/opam-repository/packages/bisect_ppx/bisect_ppx.dev/opam b/opam-repository/packages/bisect_ppx/bisect_ppx.dev/opam new file mode 100644 index 0000000..8d78a8b --- /dev/null +++ b/opam-repository/packages/bisect_ppx/bisect_ppx.dev/opam @@ -0,0 +1,33 @@ +opam-version: "2.0" +name: "bisect_ppx" +version: "dev" +synopsis: "Code coverage for OCaml" +license: "MIT" +homepage: "https://github.com/aantron/bisect_ppx" +bug-reports: "https://github.com/aantron/bisect_ppx/issues" +dev-repo: "git+https://github.com/aantron/bisect_ppx.git" +authors: [ + "Xavier Clerc " + "Leonid Rozenberg " + "Anton Bachin " +] +maintainer: [ + "Anton Bachin " + "Leonid Rozenberg " +] +depends: [ + "base-unix" + "cmdliner" {>= "1.3.0"} + "dune" {>= "2.9.0"} + "ocaml" {>= "4.03.0"} + "ppxlib" {>= "0.36.0"} +] +build: [ + ["dune" "build" "-p" name "-j" jobs] +] +url { + src: + "https://github.com/aantron/bisect_ppx/archive/7061d643ff492b0045796357ee6917ded21fb1f0.tar.gz" + checksum: + "sha256=7ea9ec62296768c6f7a29b48b7e3a5c10fc3ae8a162c29886f404529a4f1686a" +} diff --git a/opam-repository/repo b/opam-repository/repo new file mode 100644 index 0000000..013b84d --- /dev/null +++ b/opam-repository/repo @@ -0,0 +1 @@ +opam-version: "2.0" diff --git a/requirements/docs.in b/requirements/docs.in new file mode 100644 index 0000000..e079232 --- /dev/null +++ b/requirements/docs.in @@ -0,0 +1,2 @@ +mkdocs>=1.6.1,<2 +mkdocs-material>=9.7.6,<10 diff --git a/requirements/docs.lock b/requirements/docs.lock new file mode 100644 index 0000000..1b05185 --- /dev/null +++ b/requirements/docs.lock @@ -0,0 +1,75 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile --python-version 3.12 --no-python-downloads requirements/docs.in --output-file requirements/docs.lock +babel==2.18.0 + # via mkdocs-material +backrefs==8.0 + # via mkdocs-material +certifi==2026.7.22 + # via requests +charset-normalizer==3.5.1 + # via requests +click==8.4.2 + # via mkdocs +colorama==0.4.6 + # via mkdocs-material +ghp-import==2.1.0 + # via mkdocs +idna==3.19 + # via requests +jinja2==3.1.6 + # via + # mkdocs + # mkdocs-material +markdown==3.10.3 + # via + # mkdocs + # mkdocs-material + # pymdown-extensions +markupsafe==3.0.3 + # via + # jinja2 + # mkdocs +mergedeep==1.3.4 + # via + # mkdocs + # mkdocs-get-deps +mkdocs==1.6.1 + # via + # -r requirements/docs.in + # mkdocs-material +mkdocs-get-deps==0.2.2 + # via mkdocs +mkdocs-material==9.7.7 + # via -r requirements/docs.in +mkdocs-material-extensions==1.3.1 + # via mkdocs-material +packaging==26.3 + # via mkdocs +paginate==0.5.7 + # via mkdocs-material +pathspec==1.1.1 + # via mkdocs +platformdirs==4.11.3 + # via mkdocs-get-deps +pygments==2.21.0 + # via mkdocs-material +pymdown-extensions==11.0.1 + # via mkdocs-material +python-dateutil==2.9.0.post0 + # via ghp-import +pyyaml==6.0.3 + # via + # mkdocs + # mkdocs-get-deps + # pymdown-extensions + # pyyaml-env-tag +pyyaml-env-tag==1.1 + # via mkdocs +requests==2.34.2 + # via mkdocs-material +six==1.17.0 + # via python-dateutil +urllib3==2.7.0 + # via requests +watchdog==6.0.0 + # via mkdocs diff --git a/requirements/schema.in b/requirements/schema.in new file mode 100644 index 0000000..9ccf3ef --- /dev/null +++ b/requirements/schema.in @@ -0,0 +1 @@ +jsonschema[format-nongpl]==4.26.0 diff --git a/requirements/schema.lock b/requirements/schema.lock new file mode 100644 index 0000000..9b5e31c --- /dev/null +++ b/requirements/schema.lock @@ -0,0 +1,50 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile --python-version 3.12 --no-python-downloads requirements/schema.in --output-file requirements/schema.lock +arrow==1.4.0 + # via isoduration +attrs==26.1.0 + # via + # jsonschema + # referencing +fqdn==1.5.1 + # via jsonschema +idna==3.19 + # via jsonschema +isoduration==20.11.0 + # via jsonschema +jsonpointer==3.1.1 + # via jsonschema +jsonschema==4.26.0 + # via -r requirements/schema.in +jsonschema-specifications==2025.9.1 + # via jsonschema +lark==1.3.1 + # via rfc3987-syntax +python-dateutil==2.9.0.post0 + # via arrow +referencing==0.37.0 + # via + # jsonschema + # jsonschema-specifications +rfc3339-validator==0.1.4 + # via jsonschema +rfc3986-validator==0.1.1 + # via jsonschema +rfc3987-syntax==1.1.0 + # via jsonschema +rpds-py==2026.6.3 + # via + # jsonschema + # referencing +six==1.17.0 + # via + # python-dateutil + # rfc3339-validator +typing-extensions==4.16.0 + # via referencing +tzdata==2026.3 + # via arrow +uri-template==1.3.0 + # via jsonschema +webcolors==25.10.0 + # via jsonschema diff --git a/scripts/bootstrap-development-environment b/scripts/bootstrap-development-environment new file mode 100755 index 0000000..35a9ca2 --- /dev/null +++ b/scripts/bootstrap-development-environment @@ -0,0 +1,101 @@ +#!/bin/sh + +set -eu + +repository_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +schema_environment="$repository_root/.venv-schema" +schema_lock="$repository_root/requirements/schema.lock" +coverage_repository_name="trading-engine-coverage" +coverage_repository="$repository_root/opam-repository" +dependency_band=${1:-locked} + +case "$dependency_band" in + locked | lowest | highest) ;; + *) + printf '%s\n' \ + "error: dependency band must be one of: locked, lowest, highest" >&2 + exit 2 + ;; +esac + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + printf '%s\n' "error: $1 is required; install it and rerun 'make bootstrap'" >&2 + exit 1 + fi +} + +require_command opam +require_command uv +require_command python3 + +if [ ! -f "$repository_root/_opam/.opam-switch/switch-config" ]; then + printf '%s\n' "Creating the repository-local OCaml 5.5.0 switch..." + opam switch create "$repository_root" 5.5.0 --no-install --yes +fi + +printf '%s\n' "Registering the checksummed OCaml coverage package..." +opam repository add "$coverage_repository_name" "$coverage_repository" \ + --rank 1 \ + --switch "$repository_root" \ + --yes + +if opam pin list --switch "$repository_root" --short | grep -Fqx "bisect_ppx"; then + opam pin remove bisect_ppx \ + --no-action \ + --switch "$repository_root" \ + --yes +fi + +printf '%s\n' "Installing $dependency_band OCaml development dependencies..." +case "$dependency_band" in + locked) + opam install "$repository_root" \ + --deps-only \ + --with-test \ + --locked \ + --require-checksums \ + --switch "$repository_root" \ + --yes + ;; + lowest) + opam install "$repository_root" \ + --criteria='+count[version-lag,solution]' \ + --deps-only \ + --with-test \ + --require-checksums \ + --switch "$repository_root" \ + --yes + ;; + highest) + opam install "$repository_root" \ + --criteria='-count[version-lag,solution]' \ + --deps-only \ + --with-test \ + --require-checksums \ + --switch "$repository_root" \ + --yes + ;; +esac + +if [ ! -x "$schema_environment/bin/python" ]; then + printf '%s\n' "Creating the repository-local schema environment..." + uv venv \ + --python "$(command -v python3)" \ + --no-python-downloads \ + "$schema_environment" +fi + +printf '%s\n' "Installing locked JSON Schema dependencies..." +uv pip sync \ + --python "$schema_environment/bin/python" \ + --no-python-downloads \ + --strict \ + "$schema_lock" + +if [ "$dependency_band" = locked ]; then + "$repository_root/scripts/check-development-environment" +else + printf '%s\n' \ + "$dependency_band dependency band is ready; the locked environment check does not apply." +fi diff --git a/scripts/bootstrap-documentation-environment b/scripts/bootstrap-documentation-environment new file mode 100755 index 0000000..7cf8985 --- /dev/null +++ b/scripts/bootstrap-documentation-environment @@ -0,0 +1,36 @@ +#!/bin/sh + +set -eu + +repository_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +documentation_environment="$repository_root/.venv-docs" +documentation_lock="$repository_root/requirements/docs.lock" + +if [ ! -f "$repository_root/_opam/.opam-switch/switch-config" ]; then + printf '%s\n' "error: run 'make bootstrap' before bootstrapping documentation" >&2 + exit 1 +fi + +printf '%s\n' "Installing locked OCaml documentation dependencies..." +opam install "$repository_root" \ + --deps-only \ + --locked \ + --with-doc \ + --require-checksums \ + --switch "$repository_root" \ + --yes + +if [ ! -x "$documentation_environment/bin/python" ]; then + printf '%s\n' "Creating the repository-local documentation environment..." + uv venv \ + --python "$(command -v python3)" \ + --no-python-downloads \ + "$documentation_environment" +fi + +printf '%s\n' "Installing locked documentation site dependencies..." +uv pip sync \ + --python "$documentation_environment/bin/python" \ + --no-python-downloads \ + --strict \ + "$documentation_lock" diff --git a/scripts/build-documentation-site b/scripts/build-documentation-site new file mode 100755 index 0000000..d444b7e --- /dev/null +++ b/scripts/build-documentation-site @@ -0,0 +1,40 @@ +#!/bin/sh + +set -eu + +repository_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +documentation_source="$repository_root/_build/documentation-source" +documentation_site="$repository_root/site" +generated_api="$repository_root/_build/default/_doc/_html" + +rm -rf -- "$documentation_source" "$documentation_site" +mkdir -p "$documentation_source" + +sed \ + -e 's#(.github/SUPPORT.md)#(SUPPORT.md)#g' \ + -e 's#(.github/SECURITY.md)#(SECURITY.md)#g' \ + "$repository_root/README.md" >"$documentation_source/index.md" +sed 's#(.github/SECURITY.md)#(SECURITY.md)#g' \ + "$repository_root/CONTRIBUTING.md" >"$documentation_source/CONTRIBUTING.md" +cp "$repository_root/CHANGELOG.md" "$documentation_source/CHANGELOG.md" +sed 's#(../docs/#(docs/#g' \ + "$repository_root/.github/SECURITY.md" >"$documentation_source/SECURITY.md" +sed \ + -e 's#(../README.md)#(index.md)#g' \ + -e 's#(../docs/#(docs/#g' \ + "$repository_root/.github/SUPPORT.md" >"$documentation_source/SUPPORT.md" +cp -R "$repository_root/docs" "$documentation_source/docs" +cp -R "$repository_root/contracts" "$documentation_source/contracts" + +opam exec --switch "$repository_root" -- dune build @doc +cp -R "$generated_api" "$documentation_source/api" + +sed 's##[Browse the generated `Trading_engine` module index](../api/trading_engine/Trading_engine/index.html).#' \ + "$repository_root/docs/api-reference.md" \ + >"$documentation_source/docs/api-reference.md" + +"$repository_root/.venv-docs/bin/mkdocs" build \ + --strict \ + --config-file "$repository_root/mkdocs.yml" +"$repository_root/.venv-docs/bin/python" \ + "$repository_root/scripts/check-documentation.py" site diff --git a/scripts/build-release-artifacts b/scripts/build-release-artifacts new file mode 100755 index 0000000..b105b56 --- /dev/null +++ b/scripts/build-release-artifacts @@ -0,0 +1,112 @@ +#!/bin/sh + +set -eu + +repository_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +output=${1:-"$repository_root/release"} +requested_version=${2:-} + +fail() { + printf '%s\n' "error: $1" >&2 + exit 1 +} + +case "$output" in + "$repository_root/release" | /tmp/trading-engine-release-check.*/first | /tmp/trading-engine-release-check.*/second) ;; + *) fail "release output must be the repository release directory or checker workspace" ;; +esac + +command -v git >/dev/null 2>&1 || fail "git is required" +command -v gzip >/dev/null 2>&1 || fail "gzip is required" +command -v tar >/dev/null 2>&1 || fail "tar is required" +tar --version | grep -Fq "GNU tar" || fail "GNU tar is required" + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) target=linux-x86_64 ;; + *) fail "the checked release target is Linux x86_64" ;; +esac + +version=$(sed -n 's/^(version \([^)]*\))$/\1/p' "$repository_root/dune-project") +[ -n "$version" ] || fail "dune-project version is missing" +if [ -n "$requested_version" ] && [ "$requested_version" != "$version" ]; then + fail "requested version $requested_version differs from dune-project $version" +fi +case "$version" in + *[!0-9A-Za-z.+~-]* | "") fail "version contains unsupported characters" ;; +esac + +if [ "${RELEASE_ALLOW_DIRTY:-0}" != 1 ]; then + git -C "$repository_root" diff --quiet || fail "tracked worktree changes are not releasable" + git -C "$repository_root" diff --cached --quiet || fail "staged changes are not releasable" +fi + +revision=$(git -C "$repository_root" rev-parse HEAD) +epoch=${SOURCE_DATE_EPOCH:-$(git -C "$repository_root" show -s --format=%ct HEAD)} +case "$epoch" in + *[!0-9]* | "") fail "SOURCE_DATE_EPOCH must be a nonnegative integer" ;; +esac +export SOURCE_DATE_EPOCH=$epoch + +package="trading-engine-$version" +workspace=$(mktemp -d /tmp/trading-engine-release.XXXXXX) +trap 'rm -rf -- "$workspace"' EXIT HUP INT TERM + +rm -rf -- "$output" +mkdir -p "$output" + +make -C "$repository_root" docs-build +opam exec --switch "$repository_root" -- dune build --root "$repository_root" @install + +install_root="$repository_root/_build/release-install" +rm -rf -- "$install_root" +opam exec --switch "$repository_root" -- dune install \ + --root "$repository_root" \ + --prefix "$install_root/$package" + +contracts_root="$workspace/contracts/$package" +documentation_root="$workspace/documentation/$package" +mkdir -p "$contracts_root" "$documentation_root" +cp -R "$repository_root/contracts" "$contracts_root/contracts" +cp -R "$repository_root/site/." "$documentation_root/" + +normalize_tree() { + find "$1" -exec touch -h -d "@$epoch" {} + +} + +create_archive() { + archive_root=$1 + archive_name=$2 + normalize_tree "$archive_root" + tar \ + --sort=name \ + --format=pax \ + --mtime="@$epoch" \ + --owner=0 \ + --group=0 \ + --numeric-owner \ + --pax-option=delete=atime,delete=ctime \ + -C "$archive_root" \ + -cf - \ + "$package" | gzip -n -9 >"$output/$archive_name" +} + +create_archive "$install_root" "$package-$target.tar.gz" +create_archive "$workspace/contracts" "$package-contracts.tar.gz" +create_archive "$workspace/documentation" "$package-documentation.tar.gz" + +git -C "$repository_root" archive \ + --format=tar \ + --prefix="$package/" \ + HEAD | gzip -n -9 >"$output/$package-source.tar.gz" +cp "$repository_root/trading_engine.opam" "$output/$package.opam" + +python3 "$repository_root/scripts/release_artifacts.py" generate \ + --root "$repository_root" \ + --output "$output" \ + --version "$version" \ + --target "$target" \ + --revision "$revision" \ + --epoch "$epoch" + +printf '%s\n' "Release candidate: $version ($target, $revision)" +find "$output" -maxdepth 1 -type f -printf '%f\n' | sort diff --git a/scripts/check-deterministic-journals b/scripts/check-deterministic-journals new file mode 100755 index 0000000..55304ec --- /dev/null +++ b/scripts/check-deterministic-journals @@ -0,0 +1,41 @@ +#!/bin/sh + +set -eu + +repository_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +engine="$repository_root/_build/default/bin/main.exe" +temporary_root=$(mktemp -d "${TMPDIR:-/tmp}/trading-engine-determinism.XXXXXX") + +cleanup() { + rm -rf -- "$temporary_root" +} +trap cleanup EXIT HUP INT TERM + +export LC_ALL=C +export TZ=UTC + +compare_journal() { + name=$1 + actual="$temporary_root/$name.journal.jsonl" + + "$engine" \ + --input "$repository_root/contracts/v1/fixtures/$name.scenario.json" \ + --journal "$actual" \ + >"$temporary_root/$name.stdout" \ + 2>"$temporary_root/$name.stderr" + + expected="$repository_root/contracts/v1/fixtures/$name.journal.jsonl" + if ! cmp -s "$expected" "$actual"; then + printf '%s\n' "error: $name journal differs from its canonical bytes" >&2 + diff -u "$expected" "$actual" >&2 || true + exit 1 + fi + + byte_count=$(wc -c <"$actual" | tr -d ' ') + printf '%s\n' "$name: exact journal match ($byte_count bytes)" +} + +compare_journal demo +compare_journal fill-clipped +compare_journal quote-trade +compare_journal order-book diff --git a/scripts/check-development-environment b/scripts/check-development-environment new file mode 100755 index 0000000..c9f9653 --- /dev/null +++ b/scripts/check-development-environment @@ -0,0 +1,71 @@ +#!/bin/sh + +set -eu + +repository_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +local_switch="$repository_root/_opam" +schema_python="$repository_root/.venv-schema/bin/python" +coverage_repository_name="trading-engine-coverage" +coverage_repository_url="file://$repository_root/opam-repository" + +fail() { + printf '%s\n' "error: $1" >&2 + printf '%s\n' "run 'make bootstrap' to create or update the local environment" >&2 + exit 1 +} + +command -v opam >/dev/null 2>&1 || fail "opam is not installed" +command -v uv >/dev/null 2>&1 || fail "uv is not installed" + +if [ ! -f "$local_switch/.opam-switch/switch-config" ]; then + fail "the repository-local opam switch is missing" +fi + +actual_switch=$(opam var prefix --switch "$repository_root" 2>/dev/null) || + fail "opam cannot read the repository-local switch" +if [ "$actual_switch" != "$local_switch" ]; then + fail "opam selected '$actual_switch' instead of '$local_switch'" +fi + +if ! opam repository list --switch "$repository_root" --short --color never 2>/dev/null \ + | grep -Fqx "$coverage_repository_name"; then + fail "the checksummed OCaml coverage package repository is not registered" +fi +coverage_repository_actual=$( + opam repository list --switch "$repository_root" --color never 2>/dev/null \ + | awk '$2 == "trading-engine-coverage" { print $3 }' +) +if [ "$coverage_repository_actual" != "$coverage_repository_url" ]; then + fail "the OCaml coverage package repository has an unexpected source" +fi +if opam pin list --switch "$repository_root" --short --color never 2>/dev/null \ + | grep -Fqx "bisect_ppx"; then + fail "bisect_ppx must resolve from the checksummed package repository" +fi + +if ! pending_actions=$( + opam install "$repository_root" \ + --deps-only \ + --with-test \ + --locked \ + --show-actions \ + --color never \ + --switch "$repository_root" 2>&1 +); then + printf '%s\n' "$pending_actions" >&2 + fail "opam could not evaluate the locked development dependencies" +fi +if ! printf '%s\n' "$pending_actions" | grep -Fqx "Nothing to do."; then + printf '%s\n' "$pending_actions" >&2 + fail "the locked OCaml development dependencies are stale" +fi + +if [ ! -x "$schema_python" ]; then + fail "the repository-local schema environment is missing" +fi + +if ! "$schema_python" "$repository_root/scripts/check-schema-environment.py"; then + fail "the schema environment does not match requirements/schema.lock" +fi + +printf '%s\n' "Development environment is ready." diff --git a/scripts/check-documentation.py b/scripts/check-documentation.py new file mode 100644 index 0000000..dfe9d50 --- /dev/null +++ b/scripts/check-documentation.py @@ -0,0 +1,202 @@ +"""Validate documentation sources and generated site topology.""" + +from __future__ import annotations + +import filecmp +import re +import sys +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import unquote, urlsplit + + +REPOSITORY_ROOT = Path(__file__).resolve().parent.parent +LINK = re.compile(r"(? None: + super().__init__() + self.links: list[str] = [] + + def handle_starttag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + if tag != "a": + return + for name, value in attrs: + if name == "href" and value is not None: + self.links.append(value) + + +def source_markdown_files(root: Path = REPOSITORY_ROOT) -> tuple[Path, ...]: + """Return every Markdown source that belongs to the published site.""" + fixed = ( + root / "README.md", + root / "CONTRIBUTING.md", + root / "CHANGELOG.md", + root / ".github" / "SECURITY.md", + root / ".github" / "SUPPORT.md", + ) + discovered = tuple(sorted((root / "docs").rglob("*.md"))) + tuple( + sorted((root / "contracts").rglob("README.md")) + ) + return fixed + discovered + + +def markdown_link_failures(path: Path, root: Path = REPOSITORY_ROOT) -> list[str]: + """Return actionable failures for repository-relative Markdown links.""" + failures: list[str] = [] + for target in LINK.findall(path.read_text(encoding="utf-8")): + parsed = urlsplit(target) + if parsed.scheme == "http": + failures.append(f"{path}: insecure external link {target}") + continue + if parsed.scheme or parsed.netloc or target.startswith("mailto:"): + continue + clean = unquote(parsed.path) + if not clean: + continue + if path == root / "docs" / "api-reference.md" and clean.startswith("../api/"): + continue + resolved = (path.parent / clean).resolve() + if not resolved.is_file(): + failures.append(f"{path}: missing link target {target}") + return failures + + +def source_failures(root: Path = REPOSITORY_ROOT) -> list[str]: + """Validate source topology, navigation, and local links.""" + failures: list[str] = [] + config = (root / "mkdocs.yml").read_text(encoding="utf-8") + for relative in REQUIRED_NAVIGATION: + if relative not in config: + failures.append(f"mkdocs.yml: navigation is missing {relative}") + for path in source_markdown_files(root): + if not path.is_file(): + failures.append(f"missing documentation source: {path}") + continue + failures.extend(markdown_link_failures(path, root)) + api_page = root / "docs" / "api-reference.md" + if "" not in api_page.read_text(encoding="utf-8"): + failures.append("docs/api-reference.md: generated API marker is missing") + return failures + + +def _site_target(site: Path, page: Path, href: str) -> Path | None: + parsed = urlsplit(href) + if parsed.scheme or parsed.netloc or href.startswith(("mailto:", "javascript:")): + return None + clean = unquote(parsed.path) + if not clean: + return None + if clean.startswith("/trading-engine/"): + target = site / clean.removeprefix("/trading-engine/") + elif clean.startswith("/"): + return site / "__invalid_absolute_path__" + else: + target = (page.parent / clean).resolve() + if clean.endswith("/") or not target.suffix: + target /= "index.html" + return target + + +def generated_link_failures(site: Path) -> list[str]: + """Return broken local links from generated HTML pages.""" + failures: list[str] = [] + for page in sorted(site.rglob("*.html")): + parser = _AnchorParser() + parser.feed(page.read_text(encoding="utf-8")) + for href in parser.links: + target = _site_target(site, page, href) + if target is not None and not target.is_file(): + failures.append(f"{page.relative_to(site)}: broken generated link {href}") + return failures + + +def site_failures(root: Path = REPOSITORY_ROOT) -> list[str]: + """Validate generated pages, public modules, and exact contract assets.""" + site = root / "site" + failures: list[str] = [] + required_pages = ( + site / "index.html", + site / "docs" / "architecture" / "index.html", + site / "docs" / "execution-model" / "index.html", + site / "docs" / "scenario" / "index.html", + site / "docs" / "security-maintenance" / "index.html", + site / "docs" / "repository-governance" / "index.html", + site / "SECURITY" / "index.html", + site / "docs" / "api-reference" / "index.html", + site / "contracts" / "v1" / "index.html", + site / "api" / "trading_engine" / "Trading_engine" / "index.html", + ) + for page in required_pages: + if not page.is_file(): + failures.append(f"generated documentation is missing {page.relative_to(site)}") + + api_root = site / "api" / "trading_engine" / "Trading_engine" + for interface in sorted((root / "lib").glob("*.mli")): + if interface.stem in PUBLIC_MODULE_EXCLUSIONS: + continue + module = interface.stem.capitalize() + page = api_root / module / "index.html" + if not page.is_file(): + failures.append(f"generated API is missing public module {module}") + + for source in sorted((root / "contracts").rglob("*")): + if not source.is_file() or source.suffix not in {".json", ".jsonl"}: + continue + published = site / source.relative_to(root) + if not published.is_file(): + failures.append(f"published contracts are missing {source.relative_to(root)}") + elif not filecmp.cmp(source, published, shallow=False): + failures.append(f"published contract differs from {source.relative_to(root)}") + + failures.extend(generated_link_failures(site)) + return failures + + +def main(argv: list[str]) -> int: + if len(argv) != 2 or argv[1] not in {"source", "site"}: + print("usage: check-documentation.py source|site", file=sys.stderr) + return 2 + failures = source_failures() if argv[1] == "source" else site_failures() + if failures: + print("\n".join(failures), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/check-ocaml-coverage b/scripts/check-ocaml-coverage new file mode 100755 index 0000000..f2a338d --- /dev/null +++ b/scripts/check-ocaml-coverage @@ -0,0 +1,47 @@ +#!/bin/sh +set -eu + +repository_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +coverage_root="$repository_root/_coverage" +coverage_data="$coverage_root/data" +coverage_build="$repository_root/_build-coverage" + +if [ "$coverage_root" != "$repository_root/_coverage" ] \ + || [ "$coverage_build" != "$repository_root/_build-coverage" ]; then + echo "refusing to clean an unexpected generated directory" >&2 + exit 1 +fi + +rm -rf -- "$coverage_root" +rm -rf -- "$coverage_build" +mkdir -p "$coverage_data" + +export BISECT_FILE="$coverage_data/bisect" +cd "$repository_root" + +opam exec --switch . -- dune runtest \ + --build-dir "$coverage_build" \ + --instrument-with bisect_ppx \ + --force +opam exec --switch . -- bisect-ppx-report summary \ + --coverage-path "$coverage_data" \ + --per-file \ + --expect bin/ \ + --expect lib/ \ + | tee "$coverage_root/summary.txt" +opam exec --switch . -- bisect-ppx-report html \ + --coverage-path "$coverage_data" \ + --expect bin/ \ + --expect lib/ \ + --sort-by-stats \ + --tree \ + --title "Trading Engine OCaml coverage" \ + -o "$coverage_root/html" +opam exec --switch . -- bisect-ppx-report cobertura \ + --coverage-path "$coverage_data" \ + --expect bin/ \ + --expect lib/ \ + "$coverage_root/cobertura.xml" +python3 scripts/check-ocaml-coverage.py \ + coverage/ocaml-policy.json \ + "$coverage_root/summary.txt" diff --git a/scripts/check-ocaml-coverage.py b/scripts/check-ocaml-coverage.py new file mode 100755 index 0000000..ea869f1 --- /dev/null +++ b/scripts/check-ocaml-coverage.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Validate the OCaml coverage result and its change history.""" + +from __future__ import annotations + +import json +import re +import sys +from decimal import Decimal +from pathlib import Path + + +SUMMARY_PATTERN = re.compile( + r"^\s*(?P\d+(?:\.\d+)?)\s+%\s+" + r"(?P\d+)/(?P\d+)\s+Project coverage\s*$", + re.MULTILINE, +) +ISSUE_PREFIX = "https://github.com/fallblu/trading-engine/issues/" + + +def fail(message: str) -> None: + raise SystemExit(f"coverage policy error: {message}") + + +def load_policy(path: Path) -> dict[str, object]: + try: + policy = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + fail(f"cannot read {path}: {error}") + if not isinstance(policy, dict): + fail("policy must be a JSON object") + return policy + + +def decimal_field(value: object, name: str) -> Decimal: + if isinstance(value, bool) or not isinstance(value, (int, float, str)): + fail(f"{name} must be a number") + try: + result = Decimal(str(value)) + except Exception as error: + fail(f"{name} is invalid: {error}") + if result < 0 or result > 100: + fail(f"{name} must be between 0 and 100") + return result + + +def validate_policy(policy: dict[str, object]) -> Decimal: + if policy.get("format_version") != 1: + fail("format_version must be 1") + minimum = decimal_field( + policy.get("minimum_coverage_percent"), "minimum_coverage_percent" + ) + history = policy.get("threshold_history") + if not isinstance(history, list) or not history: + fail("threshold_history must contain at least one entry") + + previous: Decimal | None = None + for index, entry in enumerate(history): + if not isinstance(entry, dict): + fail(f"threshold_history[{index}] must be an object") + entry_minimum = decimal_field( + entry.get("minimum_coverage_percent"), + f"threshold_history[{index}].minimum_coverage_percent", + ) + reason = entry.get("reason") + issue_url = entry.get("issue_url") + if not isinstance(reason, str) or not reason.strip(): + fail(f"threshold_history[{index}] must explain the change") + if not isinstance(issue_url, str) or not issue_url.startswith(ISSUE_PREFIX): + fail(f"threshold_history[{index}] must link a repository issue") + if previous is not None and entry_minimum < previous and len(reason.strip()) < 20: + fail(f"threshold_history[{index}] must explain the threshold reduction") + previous = entry_minimum + + if previous != minimum: + fail("the latest threshold history entry must match the active minimum") + + exclusions = policy.get("excluded_paths") + if not isinstance(exclusions, list): + fail("excluded_paths must be a list") + for index, exclusion in enumerate(exclusions): + if not isinstance(exclusion, dict): + fail(f"excluded_paths[{index}] must be an object") + path = exclusion.get("path") + reason = exclusion.get("reason") + if not isinstance(path, str) or not path.strip(): + fail(f"excluded_paths[{index}] must name a path") + if not isinstance(reason, str) or not reason.strip(): + fail(f"excluded_paths[{index}] must explain the exclusion") + return minimum + + +def main() -> None: + if len(sys.argv) != 3: + fail("usage: check-ocaml-coverage.py POLICY SUMMARY") + policy_path, summary_path = map(Path, sys.argv[1:]) + minimum = validate_policy(load_policy(policy_path)) + try: + summary = summary_path.read_text(encoding="utf-8") + except OSError as error: + fail(f"cannot read {summary_path}: {error}") + match = SUMMARY_PATTERN.search(summary) + if match is None: + fail("summary does not contain project coverage") + covered = int(match.group("covered")) + total = int(match.group("total")) + if total <= 0 or covered > total: + fail("summary contains invalid coverage counts") + actual = Decimal(covered * 100) / Decimal(total) + if actual < minimum: + fail( + f"{covered}/{total} points ({actual:.2f}%) is below the " + f"{minimum}% minimum" + ) + print( + f"OCaml coverage {covered}/{total} points ({actual:.2f}%) " + f"meets the {minimum}% minimum" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/check-release-artifacts b/scripts/check-release-artifacts new file mode 100755 index 0000000..c47bcc0 --- /dev/null +++ b/scripts/check-release-artifacts @@ -0,0 +1,38 @@ +#!/bin/sh + +set -eu + +repository_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +version=${VERSION:-} +target=linux-x86_64 +revision=$(git -C "$repository_root" rev-parse HEAD) +epoch=$(git -C "$repository_root" show -s --format=%ct HEAD) +workspace=$(mktemp -d /tmp/trading-engine-release-check.XXXXXX) +trap 'rm -rf -- "$workspace"' EXIT HUP INT TERM + +export SOURCE_DATE_EPOCH=$epoch + +opam lint "$repository_root/trading_engine.opam" +rm -rf -- "$repository_root/_build" +"$repository_root/scripts/build-release-artifacts" "$workspace/first" "$version" +rm -rf -- "$repository_root/_build" +"$repository_root/scripts/build-release-artifacts" "$workspace/second" "$version" + +diff -r --no-dereference "$workspace/first" "$workspace/second" + +if [ -z "$version" ]; then + version=$(sed -n 's/^(version \([^)]*\))$/\1/p' "$repository_root/dune-project") +fi +python3 "$repository_root/scripts/release_artifacts.py" verify \ + --root "$repository_root" \ + --output "$workspace/first" \ + --version "$version" \ + --target "$target" \ + --revision "$revision" \ + --epoch "$epoch" + +release_output="$repository_root/release" +rm -rf -- "$release_output" +mkdir -p "$release_output" +cp -R "$workspace/first/." "$release_output/" +printf '%s\n' "Release artifacts reproduced exactly and installation passed." diff --git a/scripts/check-schema-environment.py b/scripts/check-schema-environment.py new file mode 100644 index 0000000..49fac52 --- /dev/null +++ b/scripts/check-schema-environment.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import importlib.metadata +import pathlib +import re +import sys + + +PIN = re.compile(r"^([A-Za-z0-9_.-]+)==([^\s;]+)$") + + +def normalized(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def locked_versions(lock_path: pathlib.Path) -> dict[str, str]: + locked: dict[str, str] = {} + for raw_line in lock_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + match = PIN.fullmatch(line) + if match is not None: + locked[normalized(match.group(1))] = match.group(2) + if not locked: + raise ValueError(f"no pinned dependencies found in {lock_path}") + return locked + + +def installed_versions() -> dict[str, str]: + installed: dict[str, str] = {} + for distribution in importlib.metadata.distributions(): + name = distribution.metadata.get("Name") + if name is not None: + installed[normalized(name)] = distribution.version + return installed + + +def dependency_differences( + locked: dict[str, str], installed: dict[str, str] +) -> list[str]: + differences = [ + f"{name}: expected {version}, found {installed.get(name, 'missing')}" + for name, version in sorted(locked.items()) + if installed.get(name) != version + ] + unexpected = sorted(set(installed) - set(locked)) + differences.extend(f"{name}: installed but not locked" for name in unexpected) + return differences + + +def main() -> int: + repository_root = pathlib.Path(__file__).resolve().parent.parent + lock_path = repository_root / "requirements" / "schema.lock" + try: + locked = locked_versions(lock_path) + except (OSError, ValueError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + installed = installed_versions() + differences = dependency_differences(locked, installed) + if differences: + print("error: schema dependency mismatch:", file=sys.stderr) + for difference in differences: + print(f" - {difference}", file=sys.stderr) + return 1 + + try: + from jsonschema import ( # noqa: PLC0415 + Draft4Validator, + Draft6Validator, + Draft7Validator, + Draft201909Validator, + Draft202012Validator, + FormatChecker, + ) + + validators = ( + Draft4Validator, + Draft6Validator, + Draft7Validator, + Draft201909Validator, + Draft202012Validator, + ) + if not validators or not FormatChecker.checkers: + raise RuntimeError("JSON Schema validators or format checkers are unavailable") + except (ImportError, RuntimeError) as error: + print(f"error: incomplete JSON Schema installation: {error}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_artifacts.py b/scripts/release_artifacts.py new file mode 100644 index 0000000..b967524 --- /dev/null +++ b/scripts/release_artifacts.py @@ -0,0 +1,503 @@ +"""Generate and verify deterministic release-candidate metadata.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import re +import subprocess +import tarfile +import tempfile +from pathlib import Path, PurePosixPath +from urllib.parse import quote + + +OPAM_DEPENDENCY = re.compile( + r'^\s*"(?P[^"]+)"\s+\{=\s+"(?P[^"]+)"' +) +PYTHON_DEPENDENCY = re.compile( + r"^(?P[A-Za-z0-9_.-]+)==(?P[^\s;]+)$" +) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def artifact_names(version: str, target: str) -> dict[str, str]: + stem = f"trading-engine-{version}" + return { + "binary": f"{stem}-{target}.tar.gz", + "source": f"{stem}-source.tar.gz", + "contracts": f"{stem}-contracts.tar.gz", + "documentation": f"{stem}-documentation.tar.gz", + "opam": f"{stem}.opam", + } + + +def parse_opam_dependencies(path: Path) -> list[tuple[str, str, str]]: + dependencies = [] + for line in path.read_text(encoding="utf-8").splitlines(): + match = OPAM_DEPENDENCY.match(line) + if match: + dependencies.append( + ("opam", match.group("name"), match.group("version")) + ) + return dependencies + + +def parse_python_dependencies(path: Path) -> list[tuple[str, str, str]]: + dependencies = [] + for line in path.read_text(encoding="utf-8").splitlines(): + match = PYTHON_DEPENDENCY.match(line) + if match: + dependencies.append( + ("pypi", match.group("name").lower(), match.group("version")) + ) + return dependencies + + +def dependency_inventory(root: Path) -> list[tuple[str, str, str]]: + dependencies = parse_opam_dependencies(root / "trading_engine.opam.locked") + dependencies.extend(parse_python_dependencies(root / "requirements/docs.lock")) + dependencies.extend(parse_python_dependencies(root / "requirements/schema.lock")) + return sorted(set(dependencies)) + + +def spdx_id(ecosystem: str, name: str, version: str) -> str: + value = re.sub(r"[^A-Za-z0-9.-]", "-", f"{ecosystem}-{name}-{version}") + return f"SPDXRef-Package-{value}" + + +def generate_sbom( + root: Path, + version: str, + target: str, + revision: str, + epoch: int, +) -> dict[str, object]: + project_id = "SPDXRef-Package-trading-engine" + packages: list[dict[str, object]] = [ + { + "SPDXID": project_id, + "name": "trading-engine", + "versionInfo": version, + "downloadLocation": "NOASSERTION", + "filesAnalyzed": False, + "licenseConcluded": "MIT", + "licenseDeclared": "MIT", + "copyrightText": "NOASSERTION", + "primaryPackagePurpose": "APPLICATION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": f"pkg:opam/trading_engine@{quote(version)}", + } + ], + } + ] + relationships: list[dict[str, str]] = [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": project_id, + } + ] + for ecosystem, name, dependency_version in dependency_inventory(root): + dependency_id = spdx_id(ecosystem, name, dependency_version) + packages.append( + { + "SPDXID": dependency_id, + "name": name, + "versionInfo": dependency_version, + "downloadLocation": "NOASSERTION", + "filesAnalyzed": False, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "primaryPackagePurpose": "LIBRARY", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": ( + f"pkg:{ecosystem}/{quote(name)}@{quote(dependency_version)}" + ), + } + ], + } + ) + if ecosystem == "opam": + relationships.append( + { + "spdxElementId": project_id, + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": dependency_id, + } + ) + else: + relationships.append( + { + "spdxElementId": dependency_id, + "relationshipType": "BUILD_DEPENDENCY_OF", + "relatedSpdxElement": project_id, + } + ) + + created = dt.datetime.fromtimestamp(epoch, tz=dt.timezone.utc).isoformat() + return { + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": f"trading-engine-{version}-{target}", + "documentNamespace": ( + "https://github.com/fallblu/trading-engine/releases/sbom/" + f"{revision}/{target}" + ), + "creationInfo": { + "created": created.replace("+00:00", "Z"), + "creators": ["Tool: scripts/release_artifacts.py"], + "licenseListVersion": "3.27.0", + }, + "packages": packages, + "relationships": relationships, + } + + +def write_json(path: Path, value: object) -> None: + path.write_text( + json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + + +def checksum_lines(directory: Path, names: list[str]) -> str: + return "".join(f"{sha256(directory / name)} {name}\n" for name in sorted(names)) + + +def generate_metadata( + root: Path, + output: Path, + version: str, + target: str, + revision: str, + epoch: int, +) -> None: + names = artifact_names(version, target) + missing = [name for name in names.values() if not (output / name).is_file()] + if missing: + raise ValueError(f"release subjects are missing: {', '.join(missing)}") + + artifacts = [ + { + "name": names["binary"], + "role": "installable binary and OCaml package", + "contents": ["bin", "lib", "share/trading_engine/contracts", "doc"], + }, + { + "name": names["source"], + "role": "Git source", + "contents": ["tracked repository files at sourceRevision"], + }, + { + "name": names["contracts"], + "role": "versioned contracts", + "contents": ["contract READMEs", "schemas", "fixtures", "conformance"], + }, + { + "name": names["documentation"], + "role": "offline documentation site", + "contents": ["project guides", "versioned contracts", "generated OCaml API"], + }, + { + "name": names["opam"], + "role": "opam package definition", + "contents": ["trading_engine.opam"], + }, + ] + subject_names = sorted(names.values()) + subject_digests = {name: sha256(output / name) for name in subject_names} + manifest = { + "schemaVersion": 1, + "project": "fallblu/trading-engine", + "version": version, + "target": target, + "sourceRevision": revision, + "sourceDateEpoch": epoch, + "artifacts": [ + {**artifact, "sha256": subject_digests[artifact["name"]]} + for artifact in artifacts + ], + } + write_json(output / "release-manifest.json", manifest) + write_json( + output / "sbom.spdx.json", + generate_sbom(root, version, target, revision, epoch), + ) + + statement = { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + {"name": name, "digest": {"sha256": subject_digests[name]}} + for name in subject_names + ], + "predicateType": "https://slsa.dev/provenance/v1", + "predicate": { + "buildDefinition": { + "buildType": ( + "https://github.com/fallblu/trading-engine/blob/develop/" + "docs/release-artifacts.md#deterministic-build" + ), + "externalParameters": {"version": version, "target": target}, + "internalParameters": {"sourceDateEpoch": epoch}, + "resolvedDependencies": [ + { + "uri": "git+https://github.com/fallblu/trading-engine", + "digest": {"gitCommit": revision}, + }, + *[ + { + "uri": path, + "digest": {"sha256": sha256(root / path)}, + } + for path in ( + "trading_engine.opam.locked", + "requirements/docs.lock", + "requirements/schema.lock", + ) + ], + ], + }, + "runDetails": { + "builder": { + "id": ( + "https://github.com/fallblu/trading-engine/" + ".github/workflows/release-candidate.yml" + ) + } + }, + }, + } + (output / "provenance.intoto.jsonl").write_text( + json.dumps(statement, sort_keys=True, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + (output / "SUBJECTS.sha256").write_text( + checksum_lines(output, subject_names), encoding="utf-8" + ) + checksum_names = subject_names + [ + "SUBJECTS.sha256", + "provenance.intoto.jsonl", + "release-manifest.json", + "sbom.spdx.json", + ] + (output / "SHA256SUMS").write_text( + checksum_lines(output, checksum_names), encoding="utf-8" + ) + + +def parse_checksums(path: Path) -> dict[str, str]: + checksums: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + digest, separator, name = line.partition(" ") + if not separator or not re.fullmatch(r"[0-9a-f]{64}", digest): + raise ValueError(f"invalid checksum line in {path.name}: {line}") + if name in checksums: + raise ValueError(f"duplicate checksum subject in {path.name}: {name}") + checksums[name] = digest + return checksums + + +def verify_checksums(directory: Path, filename: str) -> dict[str, str]: + checksums = parse_checksums(directory / filename) + for name, expected in checksums.items(): + path = directory / name + if not path.is_file(): + raise ValueError(f"{filename} references missing file: {name}") + actual = sha256(path) + if actual != expected: + raise ValueError(f"{filename} digest mismatch for {name}") + return checksums + + +def verify_archive( + path: Path, prefix: str, required: tuple[str, ...], epoch: int +) -> None: + with tarfile.open(path, "r:gz") as archive: + names = set() + for member in archive.getmembers(): + pure = PurePosixPath(member.name) + if pure.is_absolute() or ".." in pure.parts: + raise ValueError(f"unsafe archive member in {path.name}: {member.name}") + if member.uid != 0 or member.gid != 0 or member.mtime != epoch: + raise ValueError(f"nondeterministic archive metadata in {path.name}: {member.name}") + names.add(member.name.rstrip("/")) + for relative in required: + expected = f"{prefix}/{relative}".rstrip("/") + if expected not in names: + raise ValueError(f"{path.name} is missing {expected}") + + +def verify_release( + root: Path, output: Path, version: str, target: str, revision: str, epoch: int +) -> None: + names = artifact_names(version, target) + expected_files = set(names.values()) | { + "SHA256SUMS", + "SUBJECTS.sha256", + "provenance.intoto.jsonl", + "release-manifest.json", + "sbom.spdx.json", + } + actual_files = {path.name for path in output.iterdir() if path.is_file()} + if actual_files != expected_files: + raise ValueError( + f"release file set differs: expected {sorted(expected_files)}, " + f"found {sorted(actual_files)}" + ) + + subjects = verify_checksums(output, "SUBJECTS.sha256") + if set(subjects) != set(names.values()): + raise ValueError("SUBJECTS.sha256 does not describe the distributable set") + checksums = verify_checksums(output, "SHA256SUMS") + if set(checksums) != expected_files - {"SHA256SUMS"}: + raise ValueError("SHA256SUMS does not describe every release file") + + package = f"trading-engine-{version}" + verify_archive( + output / names["binary"], + package, + ( + "bin/trading-engine", + "lib/trading_engine/opam", + "share/trading_engine/contracts/v1/scenario.schema.json", + "share/trading_engine/contracts/v1/fixtures/demo.scenario.json", + "share/trading_engine/contracts/cli/v1/result.schema.json", + "doc/trading_engine/README.md", + ), + epoch, + ) + verify_archive( + output / names["source"], + package, + ( + "trading_engine.opam", + "contracts/v1/scenario.schema.json", + "contracts/v1/fixtures/demo.scenario.json", + "contracts/cli/v1/result.schema.json", + "docs/architecture.md", + ".github/workflows/release-candidate.yml", + ), + epoch, + ) + verify_archive( + output / names["contracts"], + package, + ( + "contracts/conformance/manifest.json", + "contracts/v1/scenario.schema.json", + "contracts/v1/fixtures/demo.scenario.json", + "contracts/strategy/v1/message.schema.json", + "contracts/cli/v1/result.schema.json", + ), + epoch, + ) + verify_archive( + output / names["documentation"], + package, + ( + "index.html", + "docs/architecture/index.html", + "contracts/v1/index.html", + "contracts/v1/scenario.schema.json", + "contracts/cli/v1/result.schema.json", + "api/trading_engine/Trading_engine/index.html", + ), + epoch, + ) + + if (output / names["opam"]).read_bytes() != (root / "trading_engine.opam").read_bytes(): + raise ValueError("opam artifact differs from trading_engine.opam") + + manifest = json.loads((output / "release-manifest.json").read_text(encoding="utf-8")) + if ( + manifest.get("version") != version + or manifest.get("target") != target + or manifest.get("sourceRevision") != revision + or manifest.get("sourceDateEpoch") != epoch + ): + raise ValueError("release manifest identity differs from the requested build") + + sbom = json.loads((output / "sbom.spdx.json").read_text(encoding="utf-8")) + if sbom.get("spdxVersion") != "SPDX-2.3": + raise ValueError("SBOM is not SPDX 2.3") + if not any( + package_entry.get("name") == "trading-engine" + and package_entry.get("versionInfo") == version + for package_entry in sbom.get("packages", []) + ): + raise ValueError("SBOM does not describe the project package") + + statement = json.loads( + (output / "provenance.intoto.jsonl").read_text(encoding="utf-8") + ) + if ( + statement.get("_type") != "https://in-toto.io/Statement/v1" + or statement.get("predicateType") != "https://slsa.dev/provenance/v1" + ): + raise ValueError("provenance is not an in-toto SLSA v1 statement") + provenance_subjects = { + subject["name"]: subject["digest"]["sha256"] + for subject in statement.get("subject", []) + } + if provenance_subjects != subjects: + raise ValueError("provenance subjects differ from SUBJECTS.sha256") + + with tempfile.TemporaryDirectory() as directory: + destination = Path(directory) + with tarfile.open(output / names["binary"], "r:gz") as archive: + archive.extractall(destination, filter="data") + executable = destination / package / "bin" / "trading-engine" + result = subprocess.run( + [executable, "--capabilities"], + check=True, + capture_output=True, + text=True, + ) + capabilities = json.loads(result.stdout) + if capabilities.get("engine_version") != version: + raise ValueError("installed binary reports a different version") + + +def main() -> None: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + for command in ("generate", "verify"): + child = subparsers.add_parser(command) + child.add_argument("--root", type=Path, required=True) + child.add_argument("--output", type=Path, required=True) + child.add_argument("--version", required=True) + child.add_argument("--target", required=True) + child.add_argument("--revision", required=True) + child.add_argument("--epoch", type=int, required=True) + args = parser.parse_args() + root = args.root.resolve() + output = args.output.resolve() + if args.command == "generate": + generate_metadata( + root, output, args.version, args.target, args.revision, args.epoch + ) + else: + verify_release( + root, output, args.version, args.target, args.revision, args.epoch + ) + + +if __name__ == "__main__": + main() diff --git a/test/cli.t b/test/cli.t index e2ae7df..33fada3 100644 --- a/test/cli.t +++ b/test/cli.t @@ -1,62 +1,127 @@ $ ../bin/main.exe --version - 1.0.0 + 1.1.0 $ ../bin/main.exe --capabilities - {"engine_version":"1.0.0","scenario_contract_versions":["3"],"journal_contract_versions":["3"],"scenario_formats":["json","jsonl"],"journal_formats":["jsonl"],"execution_models":["completed_bar_v1"],"strategy_protocol_versions":["3"]} + {"engine_version":"1.1.0","scenario_contract_versions":["1"],"journal_contract_versions":["1"],"scenario_formats":["json","jsonl"],"journal_formats":["jsonl"],"execution_models":["completed_bar_v1","completed_bar_next_open_v1","completed_bar_adverse_touch_v1","quote_trade_v1","order_book_v1"],"execution_model_contracts":[{"name":"completed_bar_v1","configuration_versions":["1"],"scenario_contract_versions":["1"],"required_fields":["version","participation_bps","fee_schedules"],"configuration_required_fields":{"1":["version","participation_bps","fee_schedules"]},"supported_order_types":["market","limit","stop","stop_limit"],"data_requirements":["completed_ohlcv_bars"],"limits":{"participation_bps":{"minimum":0,"maximum":10000}}},{"name":"completed_bar_next_open_v1","configuration_versions":["1"],"scenario_contract_versions":["1"],"required_fields":["version","participation_bps","fee_schedules","spread_model","impact_model"],"configuration_required_fields":{"1":["version","participation_bps","fee_schedules","spread_model","impact_model"]},"supported_order_types":["market","limit","stop","stop_limit"],"data_requirements":["completed_ohlcv_bars","bar_volume_for_linear_impact"],"limits":{"participation_bps":{"minimum":0,"maximum":10000},"half_spread_bps":{"minimum":0,"maximum":10000},"impact_coefficient_bps":{"minimum":0,"maximum":10000}}},{"name":"completed_bar_adverse_touch_v1","configuration_versions":["1"],"scenario_contract_versions":["1"],"required_fields":["version","participation_bps","fee_schedules","spread_model","impact_model"],"configuration_required_fields":{"1":["version","participation_bps","fee_schedules","spread_model","impact_model"]},"supported_order_types":["market","limit","stop","stop_limit"],"data_requirements":["completed_ohlcv_bars","bar_volume_for_linear_impact"],"limits":{"participation_bps":{"minimum":0,"maximum":10000},"half_spread_bps":{"minimum":0,"maximum":10000},"impact_coefficient_bps":{"minimum":0,"maximum":10000}}},{"name":"quote_trade_v1","configuration_versions":["1"],"scenario_contract_versions":["1"],"required_fields":["version","participation_bps","fee_schedules"],"configuration_required_fields":{"1":["version","participation_bps","fee_schedules"]},"supported_order_types":["market","limit","stop","stop_limit"],"data_requirements":["causally_ordered_bid_ask_quotes","aggressor_classified_trades_for_passive_fills","completed_bars_for_valuation"],"limits":{"participation_bps":{"minimum":0,"maximum":10000}}},{"name":"order_book_v1","configuration_versions":["1"],"scenario_contract_versions":["1"],"required_fields":["version","participation_bps","fee_schedules","max_depth_levels"],"configuration_required_fields":{"1":["version","participation_bps","fee_schedules","max_depth_levels"]},"supported_order_types":["market","limit","stop","stop_limit"],"data_requirements":["slice_open_level_two_snapshot","contiguous_absolute_level_updates","aggressor_classified_depth_consuming_trades","completed_bars_for_valuation"],"limits":{"participation_bps":{"minimum":0,"maximum":10000},"max_depth_levels":{"minimum":1,"maximum":1024}}}],"strategy_protocol_versions":["1"],"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/v3/fixtures/demo.scenario.json - valid run=demo instruments=1 schedule=2 slices=4 scenario_sha256=3e19fa66bc6425bb8ed7a89b338080a831dd39ea778c3c7f9e8ce1d3370fbee0 + $ ../bin/main.exe --validate-only --input ../contracts/v1/fixtures/demo.scenario.json + valid run=demo instruments=1 schedule=2 slices=4 scenario_sha256=e2227af76072fab8151c3e1bd86f401293d16a736e32040efdaf8761cd397574 - $ ../bin/main.exe --validate-only --input-format jsonl --input ../contracts/v3/fixtures/demo.scenario.jsonl - valid run=demo instruments=1 schedule=2 slices=4 scenario_sha256=0615019643edcd2b75c1307456d93bfba13990cb09bdc80ffc7fac98056020cd + $ ../bin/main.exe --validate-only --input-format jsonl --input ../contracts/v1/fixtures/demo.scenario.jsonl + valid run=demo instruments=1 schedule=2 slices=4 scenario_sha256=cb5cf2c600829b54be763809138137fe53ed61fd41f50c5b4d98cb27ee528eb4 - $ ../bin/main.exe --input-format jsonl --input ../contracts/v3/fixtures/demo.scenario.jsonl --journal streamed.journal.jsonl - run=demo audits=20 orders=3 active=0 filled=2 rejected=0 - cash=9739.76812 equity=10004.76812 gross=265 realized=1.419136 unrealized=3.348984 fees=2.50188 + $ ../bin/main.exe --output-format json --validate-only --input ../contracts/v1/fixtures/demo.scenario.json | python3 -c 'import json, sys; result=json.load(sys.stdin); print(result["result_version"], result["status"], result["operation"], result["run_id"]); print(result["counts"]["instruments"], result["counts"]["slices"], result["counts"]["audits"], result["valuation"]["equity"]); print(result["hashes"]["journal_sha256"], result["artifacts"]["journal"])' + 1 success validate demo + 1 4 34 10111.979929 + None None + + $ ../bin/main.exe --input-format jsonl --input ../contracts/v1/fixtures/demo.scenario.jsonl --journal streamed.journal.jsonl --durable-artifacts + run=demo audits=34 orders=3 active=0 filled=2 rejected=0 + cash=9846.979929 equity=10111.979929 gross=265 realized=19.134573 unrealized=7.845356 fees=3.157896 journal=streamed.journal.jsonl $ wc -l < streamed.journal.jsonl - 20 + 34 - $ head -n 5 ../contracts/v3/fixtures/demo.scenario.jsonl > truncated.scenario.jsonl + $ head -n 5 ../contracts/v1/fixtures/demo.scenario.jsonl > truncated.scenario.jsonl $ ../bin/main.exe --validate-only --input-format jsonl --input truncated.scenario.jsonl trading-engine: scenario_end must terminate the scenario stream [123] - $ sed 's/"open": "100"/"open": "100.001"/' ../contracts/v3/fixtures/demo.scenario.json > invalid-tick.json + $ diagnostic=$(../bin/main.exe --diagnostic-format json --validate-only --input-format jsonl --input truncated.scenario.jsonl 2>&1); status=$?; test "$status" -eq 123; python3 - "$diagnostic" <<'PY' + > import json + > import sys + > diagnostic = json.loads(sys.argv[1]) + > print(diagnostic["diagnostic_version"], diagnostic["code"], diagnostic["phase"]) + > print(diagnostic["context"]["line"], diagnostic["context"]["sequence"], diagnostic["cause"]) + > PY + 1 scenario_stream.invalid validation + 6 6 None + + $ diagnostic=$(../bin/main.exe --output-format json --validate-only --input-format jsonl --input truncated.scenario.jsonl 2>&1 >/dev/null); status=$?; test "$status" -eq 123; python3 -c 'import json, sys; diagnostic=json.loads(sys.argv[1]); print(diagnostic["diagnostic_version"], diagnostic["code"], diagnostic["phase"])' "$diagnostic" + 1 scenario_stream.invalid validation + + $ ../bin/main.exe --output-format json --validate-only --input missing.scenario.json 2>&1 >/dev/null | python3 -c 'import json, sys; diagnostic=json.load(sys.stdin); print(diagnostic["code"], diagnostic["phase"])' + input.io input + + $ sed 's/"open": "100"/"open": "100.001"/' ../contracts/v1/fixtures/demo.scenario.json > invalid-tick.json $ ../bin/main.exe --validate-only --input invalid-tick.json trading-engine: market prices and volumes must align with instrument increments [123] - $ ../bin/main.exe --input ../contracts/v3/fixtures/demo.scenario.json + $ ../bin/main.exe --input ../contracts/v1/fixtures/demo.scenario.json trading-engine: --journal is required unless --validate-only is set [123] - $ ../bin/main.exe --validate-only --input ../contracts/v3/fixtures/demo.scenario.json --journal validation.journal.jsonl + $ ../bin/main.exe --validate-only --input ../contracts/v1/fixtures/demo.scenario.json --journal validation.journal.jsonl trading-engine: --journal cannot be used with --validate-only [123] $ test ! -e validation.journal.jsonl - $ ../bin/main.exe --input ../contracts/v3/fixtures/demo.scenario.json --journal ignored.journal.jsonl --strategy-timeout 5 + $ ../bin/main.exe --input-format jsonl --input ../contracts/v1/fixtures/demo.scenario.jsonl --journal piped-file.journal.jsonl >/dev/null + $ cat ../contracts/v1/fixtures/demo.scenario.jsonl | ../bin/main.exe --input-format jsonl --input - --journal - > piped-stdout.journal.jsonl 2> piped-summary.txt + $ cmp piped-file.journal.jsonl piped-stdout.journal.jsonl + $ python3 - piped-summary.txt piped-stdout.journal.jsonl <<'PY' + > import hashlib + > import json + > import sys + > summary_path, journal_path = sys.argv[1:] + > summary = open(summary_path, encoding="utf-8").read() + > journal_bytes = open(journal_path, "rb").read() + > completion = json.loads(journal_bytes.splitlines()[-1]) + > print("journal=stdout" in summary, completion["event_type"]) + > print(len(journal_bytes.splitlines()), hashlib.sha256(journal_bytes).hexdigest() == hashlib.sha256(open("piped-file.journal.jsonl", "rb").read()).hexdigest()) + > PY + True run_completed + 34 True + + $ ../bin/main.exe --output-format json --input-format jsonl --input ../contracts/v1/fixtures/demo.scenario.jsonl --journal - > piped-json.journal.jsonl 2> piped-json-summary.json + $ python3 -c 'import json; result=json.load(open("piped-json-summary.json")); print(result["result_version"], result["operation"], result["artifacts"]["journal"], result["hashes"]["journal_sha256"] is not None)' + 1 replay stdout True + $ cmp piped-file.journal.jsonl piped-json.journal.jsonl + + $ printf '{}\n' | ../bin/main.exe --input - --journal - + trading-engine: standard input requires --input-format jsonl; batch JSON is not supported + [123] + + $ ../bin/main.exe --input-format jsonl --input ../contracts/v1/fixtures/demo.scenario.jsonl --journal - --durable-artifacts + trading-engine: --durable-artifacts cannot be used when --journal writes to standard output + [123] + + $ ../bin/main.exe --validate-only --durable-artifacts --input ../contracts/v1/fixtures/demo.scenario.json + trading-engine: --durable-artifacts cannot be used with --validate-only + [123] + + $ ../bin/main.exe --input ../contracts/v1/fixtures/demo.scenario.json --journal ignored.journal.jsonl --strategy-timeout 5 trading-engine: --strategy-arg, --strategy-timeout, and --strategy-transcript require --strategy-executable [123] $ test ! -e ignored.journal.jsonl $ mkdir external - $ ../bin/main.exe --input ../contracts/strategy/v3/fixtures/external.scenario.json --journal external/run.journal.jsonl --strategy-executable ./fake_strategy.py --strategy-transcript external/run.strategy.jsonl --strategy-timeout 5 - run=external-demo audits=10 orders=1 active=0 filled=1 rejected=0 - cash=9794 equity=10008 gross=214 realized=0 unrealized=8 fees=0 + $ ../bin/main.exe --input ../contracts/strategy/v1/fixtures/external.scenario.json --journal external/run.journal.jsonl --strategy-executable ./fake_strategy.py --strategy-transcript external/run.strategy.jsonl --strategy-timeout 5 --durable-artifacts + run=external-demo audits=13 orders=1 active=0 filled=1 rejected=0 + cash=9793.544 equity=10007.544 gross=214 realized=0 unrealized=7.544 fees=0.456 journal=external/run.journal.jsonl strategy_transcript=external/run.strategy.jsonl $ python3 -c 'from pathlib import Path; print(len(Path("external/run.journal.jsonl").read_text().splitlines()), len(Path("external/run.strategy.jsonl").read_text().splitlines()))' - 10 14 - $ diff -u ../contracts/strategy/v3/fixtures/external.strategy.jsonl external/run.strategy.jsonl + 13 14 + $ diff -u ../contracts/strategy/v1/fixtures/external.strategy.jsonl external/run.strategy.jsonl + + $ mkdir external-json + $ ../bin/main.exe --output-format json --input ../contracts/strategy/v1/fixtures/external.scenario.json --journal external-json/run.journal.jsonl --strategy-executable ./fake_strategy.py --strategy-transcript external-json/run.strategy.jsonl --strategy-timeout 5 | python3 -c 'import hashlib, json, sys; result=json.load(sys.stdin); digest=lambda path: hashlib.sha256(open(path, "rb").read()).hexdigest(); print(result["operation"], result["run_id"], result["artifacts"]["strategy_transcript"]); print(result["hashes"]["journal_sha256"] == digest(result["artifacts"]["journal"]), result["hashes"]["strategy_transcript_sha256"] == digest(result["artifacts"]["strategy_transcript"]))' + replay external-demo external-json/run.strategy.jsonl + True True + + $ ../bin/main.exe --input ../contracts/strategy/v1/fixtures/external.scenario.json --journal ignored-stdout.journal.jsonl --strategy-executable ./fake_strategy.py --strategy-transcript - + trading-engine: --strategy-transcript does not support standard output; choose a file path + [123] + $ test ! -e ignored-stdout.journal.jsonl $ mkdir callback-ordering - $ ../bin/main.exe --input ../contracts/strategy/v3/fixtures/external.scenario.json --journal callback-ordering/run.journal.jsonl --strategy-executable ./fake_strategy.py --strategy-arg cancel-next --strategy-transcript callback-ordering/run.strategy.jsonl --strategy-timeout 5 - run=external-demo audits=10 orders=2 active=0 filled=1 rejected=0 - cash=9897 equity=10004 gross=107 realized=0 unrealized=4 fees=0 + $ ../bin/main.exe --input ../contracts/strategy/v1/fixtures/external.scenario.json --journal callback-ordering/run.journal.jsonl --strategy-executable ./fake_strategy.py --strategy-arg cancel-next --strategy-transcript callback-ordering/run.strategy.jsonl --strategy-timeout 5 + run=external-demo audits=13 orders=2 active=0 filled=1 rejected=0 + cash=9896.647 equity=10003.647 gross=107 realized=0 unrealized=3.647 fees=0.353 journal=callback-ordering/run.journal.jsonl strategy_transcript=callback-ordering/run.strategy.jsonl $ python3 -c 'import json; journal=[json.loads(line) for line in open("callback-ordering/run.journal.jsonl")]; transcript=[json.loads(line) for line in open("callback-ordering/run.strategy.jsonl")]; events=[record["event_type"] for record in journal]; cancellations=[record["payload"]["reason"] for record in journal if record["event_type"] == "order_cancelled"]; requests=[record["message"]["payload"] for record in transcript if record["direction"] == "engine_to_strategy" and record["message"]["message_type"] == "event"]; fill=next(request for request in requests if request["event"]["type"] == "fill_received"); following=requests[requests.index(fill) + 1]; print(events.count("fill_applied"), cancellations, events.count("intent_rejected")); print(len(fill["context"]["working_orders"]), len(following["context"]["working_orders"])); print(fill["context"]["latest_bars"][0]["close"], fill["context"]["portfolio"]["positions"][0]["mark"])' @@ -65,7 +130,7 @@ 107 107 $ mkdir failed-external - $ ../bin/main.exe --input ../contracts/strategy/v3/fixtures/external.scenario.json --journal failed-external/run.journal.jsonl --strategy-executable ./fake_strategy.py --strategy-arg stall --strategy-transcript failed-external/run.strategy.jsonl --strategy-timeout 0.01 + $ ../bin/main.exe --input ../contracts/strategy/v1/fixtures/external.scenario.json --journal failed-external/run.journal.jsonl --strategy-executable ./fake_strategy.py --strategy-arg stall --strategy-transcript failed-external/run.strategy.jsonl --strategy-timeout 0.01 trading-engine: strategy initialization: external strategy timed out [123] $ test ! -e failed-external/run.journal.jsonl @@ -78,7 +143,7 @@ > expected="$2" > directory="fault-$mode" > mkdir "$directory" - > output=$(../bin/main.exe --input ../contracts/strategy/v3/fixtures/external.scenario.json --journal "$directory/run.journal.jsonl" --strategy-executable ./fake_strategy.py --strategy-arg "$mode" --strategy-transcript "$directory/run.strategy.jsonl" --strategy-timeout 5 2>&1) + > output=$(../bin/main.exe --input ../contracts/strategy/v1/fixtures/external.scenario.json --journal "$directory/run.journal.jsonl" --strategy-executable ./fake_strategy.py --strategy-arg "$mode" --strategy-transcript "$directory/run.strategy.jsonl" --strategy-timeout 5 2>&1) > status=$? > test "$status" -eq 123 || return 1 > case "$output" in *"$expected"*) ;; *) return 1 ;; esac @@ -86,11 +151,53 @@ > test -e "$directory/run.journal.jsonl.partial" || return 1 > test ! -e "$directory/run.strategy.jsonl" || return 1 > test -e "$directory/run.strategy.jsonl.partial" || return 1 + > if test "$#" -eq 3; then + > python3 - "$directory/run.strategy.jsonl.partial" "$mode" "$3" <<'PY' || return 1 + > import json + > import sys + > from pathlib import Path + > path, mode, expected_code = sys.argv[1:] + > records = [json.loads(line) for line in Path(path).read_text().splitlines()] + > rejection = records[-1] + > assert rejection["strategy_diagnostic_version"] == "1" + > assert rejection["record_type"] == "rejected_strategy_response" + > assert rejection["expected_strategy_sequence"] == "1" + > assert rejection["diagnostic"]["code"] == expected_code + > assert rejection["diagnostic"]["context"]["sequence"] == "1" + > assert "strategy_protocol_version" not in rejection + > assert "direction" not in rejection + > assert "message" not in rejection + > evidence = rejection["evidence"] + > assert evidence["encoding"] == "hex" + > raw = bytes.fromhex(evidence["prefix"]) + > assert len(raw) <= 256 + > if mode == "eof": + > assert raw == b"" and evidence["observed_bytes"] == 0 + > assert evidence["truncated"] is False + > elif mode == "oversized": + > assert raw == b"x" * 256 + > assert evidence["observed_bytes"] == 1_048_577 + > assert evidence["truncated"] is True + > elif mode == "malformed": + > assert raw == b"{" and evidence["observed_bytes"] == 1 + > assert evidence["truncated"] is False + > else: + > assert evidence["observed_bytes"] == len(raw) + > assert evidence["truncated"] is False + > response = json.loads(raw) + > if mode == "bad-sequence": + > assert response["strategy_sequence"] == "999" + > elif mode == "wrong-version": + > assert response["strategy_protocol_version"] == "2" + > elif mode == "unknown-field": + > assert response["unexpected"] is True + > PY + > fi > echo "$mode: rejected" > } - $ check_strategy_failure eof "closed stdout" + $ check_strategy_failure eof "closed stdout" strategy.protocol eof: rejected - $ check_strategy_failure bad-sequence "expected strategy sequence" + $ check_strategy_failure bad-sequence "expected strategy sequence" strategy.protocol bad-sequence: rejected $ check_strategy_failure error "fixture failure" error: rejected @@ -98,18 +205,52 @@ extra-output: rejected $ check_strategy_failure nonzero "exited with code 9" nonzero: rejected - $ check_strategy_failure malformed "invalid strategy response JSON" + $ check_strategy_failure malformed "invalid strategy response JSON" strategy.protocol malformed: rejected - $ check_strategy_failure oversized "exceeds the maximum message size" + $ check_strategy_failure oversized "exceeds the maximum message size" resource.limit oversized: rejected - $ check_strategy_failure wrong-version "unsupported strategy protocol version" + $ check_strategy_failure wrong-version "unsupported strategy protocol version" strategy.protocol wrong-version: rejected - $ check_strategy_failure unknown-field "unknown or missing fields" + $ check_strategy_failure unknown-field "unknown or missing fields" strategy.protocol unknown-field: rejected + $ check_process_tree_failure () { + > mode="$1" + > expected="$2" + > directory="process-tree-$mode" + > mkdir "$directory" + > pid_path="$directory/grandchild.pid" + > output=$(../bin/main.exe --input ../contracts/strategy/v1/fixtures/external.scenario.json --journal "$directory/run.journal.jsonl" --strategy-executable ./fake_strategy.py --strategy-arg "$mode" --strategy-arg "$pid_path" --strategy-transcript "$directory/run.strategy.jsonl" --strategy-timeout 0.2 2>&1) + > status=$? + > test "$status" -eq 123 || return 1 + > case "$output" in *"$expected"*) ;; *) return 1 ;; esac + > test -s "$pid_path" || return 1 + > pid=$(cat "$pid_path") + > python3 - "$pid" <<'PY' || return 1 + > import os + > import sys + > import time + > pid = int(sys.argv[1]) + > deadline = time.monotonic() + 2 + > while True: + > try: + > os.kill(pid, 0) + > except ProcessLookupError: + > break + > if time.monotonic() >= deadline: + > raise SystemExit("grandchild process survived cleanup") + > time.sleep(0.01) + > PY + > echo "$mode: process tree reaped" + > } + $ check_process_tree_failure spawn-grandchild "timed out" + spawn-grandchild: process tree reaped + $ check_process_tree_failure grandchild-malformed "invalid strategy response JSON" + grandchild-malformed: process tree reaped + $ mkdir external-stream - $ ../bin/main.exe --input-format jsonl --input ../contracts/strategy/v3/fixtures/external.scenario.jsonl --journal external-stream/run.journal.jsonl --strategy-executable ./fake_strategy.py --strategy-transcript external-stream/run.strategy.jsonl --strategy-timeout 5 - run=external-demo audits=10 orders=1 active=0 filled=1 rejected=0 - cash=9794 equity=10008 gross=214 realized=0 unrealized=8 fees=0 + $ ../bin/main.exe --input-format jsonl --input ../contracts/strategy/v1/fixtures/external.scenario.jsonl --journal external-stream/run.journal.jsonl --strategy-executable ./fake_strategy.py --strategy-transcript external-stream/run.strategy.jsonl --strategy-timeout 5 + run=external-demo audits=13 orders=1 active=0 filled=1 rejected=0 + cash=9793.544 equity=10007.544 gross=214 realized=0 unrealized=7.544 fees=0.456 journal=external-stream/run.journal.jsonl strategy_transcript=external-stream/run.strategy.jsonl diff --git a/test/dune b/test/dune index 987fb76..34999af 100644 --- a/test/dune +++ b/test/dune @@ -2,69 +2,167 @@ (name test_engine) (modules test_support + test_diagnostic test_domain test_accounting test_execution + test_order_lifetimes + test_fee_schedules + test_financing + test_settlement + test_corporate_lifecycle test_reducer + test_reducer_properties test_checkpoint4 test_strategy_protocol + test_contract_conformance + test_boundary_failures + test_venue_calendar + test_risk_groups test_scenario test_engine) (deps - ../contracts/v3/fixtures/demo.journal.jsonl - ../contracts/v3/fixtures/demo.scenario.json - ../contracts/v3/fixtures/demo.scenario.jsonl - ../contracts/v3/journal.schema.json - ../contracts/v3/scenario-stream.schema.json - ../contracts/v3/scenario.schema.json) - (libraries trading_engine ptime yojson alcotest qcheck-core qcheck-alcotest)) + (source_tree ../contracts) + fake_strategy.py) + (libraries + trading_engine + ptime + yojson + alcotest + qcheck-core + qcheck-alcotest + unix + eio + eio_main)) -(cram - (deps - ../bin/main.exe - fake_strategy.py - ../contracts/strategy/v3/fixtures/external.scenario.json - ../contracts/strategy/v3/fixtures/external.scenario.jsonl - ../contracts/strategy/v3/fixtures/external.strategy.jsonl - ../contracts/v3/fixtures/demo.scenario.json - ../contracts/v3/fixtures/demo.scenario.jsonl)) +(executable + (name fuzz_protocol) + (modules fuzz_protocol) + (libraries trading_engine yojson unix)) (rule (alias runtest) (deps validate_schemas.py - ../contracts/v3/fixtures/demo.journal.jsonl - ../contracts/v3/fixtures/demo.scenario.json - ../contracts/v3/fixtures/demo.scenario.jsonl - ../contracts/v3/journal.schema.json - ../contracts/v3/scenario-stream.schema.json - ../contracts/v3/scenario.schema.json) + ../contracts/v1/scenario.schema.json + ../contracts/v1/scenario-stream.schema.json + ../contracts/v1/journal.schema.json + ../contracts/v1/fixtures/demo.scenario.json + ../contracts/v1/fixtures/demo.scenario.jsonl + ../contracts/v1/fixtures/demo.journal.jsonl) (action (run python3 %{dep:validate_schemas.py} - %{dep:../contracts/v3/scenario.schema.json} - %{dep:../contracts/v3/scenario-stream.schema.json} - %{dep:../contracts/v3/journal.schema.json} - %{dep:../contracts/v3/fixtures/demo.scenario.json} - %{dep:../contracts/v3/fixtures/demo.scenario.jsonl} - %{dep:../contracts/v3/fixtures/demo.journal.jsonl}))) + %{dep:../contracts/v1/scenario.schema.json} + %{dep:../contracts/v1/scenario-stream.schema.json} + %{dep:../contracts/v1/journal.schema.json} + %{dep:../contracts/v1/fixtures/demo.scenario.json} + %{dep:../contracts/v1/fixtures/demo.scenario.jsonl} + %{dep:../contracts/v1/fixtures/demo.journal.jsonl}))) (rule (alias runtest) (deps validate_strategy_schema.py - ../contracts/v3/scenario.schema.json - ../contracts/v3/journal.schema.json - ../contracts/strategy/v3/message.schema.json - ../contracts/strategy/v3/transcript.schema.json - ../contracts/strategy/v3/fixtures/external.strategy.jsonl) + ../contracts/v1/scenario.schema.json + ../contracts/v1/journal.schema.json + ../contracts/diagnostic/v1/diagnostic.schema.json + ../contracts/strategy/v1/message.schema.json + ../contracts/strategy/v1/transcript.schema.json + ../contracts/strategy/v1/fixtures/external.strategy.jsonl) (action (run python3 %{dep:validate_strategy_schema.py} - %{dep:../contracts/v3/scenario.schema.json} - %{dep:../contracts/v3/journal.schema.json} - %{dep:../contracts/strategy/v3/message.schema.json} - %{dep:../contracts/strategy/v3/transcript.schema.json} - %{dep:../contracts/strategy/v3/fixtures/external.strategy.jsonl}))) + %{dep:../contracts/v1/scenario.schema.json} + %{dep:../contracts/v1/journal.schema.json} + %{dep:../contracts/diagnostic/v1/diagnostic.schema.json} + %{dep:../contracts/strategy/v1/message.schema.json} + %{dep:../contracts/strategy/v1/transcript.schema.json} + %{dep:../contracts/strategy/v1/fixtures/external.strategy.jsonl}))) + +(rule + (alias runtest) + (deps + fuzz_protocol.exe + (source_tree ../contracts)) + (action + (run ./fuzz_protocol.exe --seed 20260821 --cases 256))) + +(cram + (deps + ../bin/main.exe + fake_strategy.py + ../contracts/strategy/v1/fixtures/external.scenario.json + ../contracts/strategy/v1/fixtures/external.scenario.jsonl + ../contracts/strategy/v1/fixtures/external.strategy.jsonl + ../contracts/v1/fixtures/demo.scenario.json + ../contracts/v1/fixtures/demo.scenario.jsonl)) + +(rule + (alias runtest) + (deps + validate_diagnostic_schema.py + ../contracts/diagnostic/v1/diagnostic.schema.json + ../contracts/diagnostic/v1/fixtures/strategy-protocol.json) + (action + (run + python3 + %{dep:validate_diagnostic_schema.py} + %{dep:../contracts/diagnostic/v1/diagnostic.schema.json} + %{dep:../contracts/diagnostic/v1/fixtures/strategy-protocol.json}))) + +(rule + (alias runtest) + (deps + test_development_environment.py + ../scripts/check-schema-environment.py) + (action + (run python3 %{dep:test_development_environment.py}))) + +(rule + (alias runtest) + (deps test_documentation.py ../scripts/check-documentation.py) + (action + (run python3 %{dep:test_documentation.py}))) + +(rule + (alias runtest) + (deps test_release_artifacts.py ../scripts/release_artifacts.py) + (action + (run python3 %{dep:test_release_artifacts.py}))) + +(rule + (alias runtest) + (deps + validate_contract_conformance.py + (source_tree ../contracts)) + (action + (run python3 %{dep:validate_contract_conformance.py}))) + +(rule + (alias runtest) + (deps + test_benchmark_replay.py + ../bench/benchmark_replay.py + ../contracts/v1/fixtures/demo.scenario.json) + (action + (run python3 %{dep:test_benchmark_replay.py}))) + +(rule + (alias runtest) + (deps + validate_cli_result.py + ../contracts/cli/v1/result.schema.json + ../contracts/v1/journal.schema.json + ../contracts/v1/fixtures/demo.scenario.json + ../bin/main.exe) + (action + (run + python3 + %{dep:validate_cli_result.py} + %{dep:../contracts/cli/v1/result.schema.json} + %{dep:../contracts/v1/journal.schema.json} + %{dep:../bin/main.exe} + %{dep:../contracts/v1/fixtures/demo.scenario.json}))) diff --git a/test/fake_strategy.py b/test/fake_strategy.py index b5f1703..7590f31 100755 --- a/test/fake_strategy.py +++ b/test/fake_strategy.py @@ -4,6 +4,9 @@ from __future__ import annotations import json +import os +import signal +import subprocess import sys import time @@ -11,6 +14,39 @@ MODE = sys.argv[1] if len(sys.argv) > 1 else "success" +if MODE in { + "spawn-grandchild", + "spawn-grandchild-success", + "grandchild-malformed", +}: + signal.signal(signal.SIGTERM, signal.SIG_IGN) + grandchild_pid_path = sys.argv[2] + grandchild = """ +import os +import signal +import sys +import time + +signal.signal(signal.SIGTERM, signal.SIG_IGN) +with open(sys.argv[1], "w", encoding="ascii") as channel: + channel.write(str(os.getpid())) + channel.flush() +time.sleep(60) +""" + subprocess.Popen( + [sys.executable, "-c", grandchild, grandchild_pid_path], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True, + ) + deadline = time.monotonic() + 1 + while not os.path.exists(grandchild_pid_path): + if time.monotonic() >= deadline: + raise RuntimeError("grandchild did not publish its PID") + time.sleep(0.01) + + def response(request: dict[str, object]) -> dict[str, object]: sequence = request["strategy_sequence"] message_type = request["message_type"] @@ -39,7 +75,12 @@ def response(request: dict[str, object]) -> dict[str, object]: "side": "buy", "quantity": "1", "order_kind": "market", + "trigger_price": None, "limit_price": None, + "time_in_force": "ioc", + "venue_id": None, + "calendar_id": None, + "expires_at": None, } payload = {"intents": [order, order]} else: @@ -57,7 +98,10 @@ def response(request: dict[str, object]) -> dict[str, object]: { "type": "emit_metric", "name": "fixture_signal", - "value": "2", + "value": {"type": "numeric", "value": "2"}, + "unit": "score", + "dimensions": {"source": "fixture"}, + "aggregation": "last", }, ] } @@ -85,7 +129,7 @@ def response(request: dict[str, object]) -> dict[str, object]: response_type = "error" payload = {"message": "unsupported request"} return { - "strategy_protocol_version": "3", + "strategy_protocol_version": request["strategy_protocol_version"], "strategy_sequence": sequence, "message_type": response_type, "payload": payload, @@ -98,7 +142,9 @@ def response(request: dict[str, object]) -> dict[str, object]: raise SystemExit(0) if MODE == "stall": time.sleep(60) - if MODE == "malformed": + if MODE == "spawn-grandchild": + time.sleep(60) + if MODE in {"malformed", "grandchild-malformed"}: print("{", flush=True) continue if MODE == "oversized": @@ -108,7 +154,7 @@ def response(request: dict[str, object]) -> dict[str, object]: if MODE == "bad-sequence": message["strategy_sequence"] = "999" if MODE == "wrong-version": - message["strategy_protocol_version"] = "1" + message["strategy_protocol_version"] = "2" if MODE == "unknown-field": message["unexpected"] = True if MODE == "error" and request["message_type"] == "event": diff --git a/test/fuzz_protocol.ml b/test/fuzz_protocol.ml new file mode 100644 index 0000000..8d74e4d --- /dev/null +++ b/test/fuzz_protocol.ml @@ -0,0 +1,487 @@ +module T = Trading_engine + +type boundary = + | Batch + | Stream + | Strategy + | Timestamp + | Decimal + | Identifier + | Json + +type seed = { name : string; boundary : boundary; input : string } +type path_component = Field of string | Index of int + +let boundary_name = function + | Batch -> "batch" + | Stream -> "stream" + | Strategy -> "strategy" + | Timestamp -> "timestamp" + | Decimal -> "decimal" + | Identifier -> "identifier" + | Json -> "json" + +let contracts = + if Sys.file_exists "../contracts/conformance/cases.json" then "../contracts" + else if Sys.file_exists "contracts/conformance/cases.json" then "contracts" + else failwith "could not locate the contract corpus" + +let contract_path relative = Filename.concat contracts relative + +let field name = function + | `Assoc fields -> List.assoc name fields + | _ -> failwith (name ^ " must be read from an object") + +let optional_field name = function + | `Assoc fields -> List.assoc_opt name fields + | _ -> failwith (name ^ " must be read from an object") + +let string_field name json = + match field name json with + | `String value -> value + | _ -> failwith (name ^ " must be a string") + +let list_field name json = + match field name json with + | `List values -> values + | _ -> failwith (name ^ " must be an array") + +let path_of_yojson = function + | `List components -> + List.map + (function + | `String name -> Field name + | `Int index -> Index index + | _ -> failwith "mutation path contains an invalid component") + components + | _ -> failwith "mutation path must be an array" + +let rec find_path path json = + match (path, json) with + | [], value -> value + | Field name :: remaining, `Assoc fields -> + find_path remaining (List.assoc name fields) + | Index index :: remaining, `List values -> + find_path remaining (List.nth values index) + | _ -> failwith "mutation path does not select a value" + +let rec set_path path replacement json = + match (path, json) with + | [], _ -> replacement + | Field name :: remaining, `Assoc fields -> + let found = ref false in + let fields = + List.map + (fun (candidate, value) -> + if String.equal candidate name then ( + found := true; + (candidate, set_path remaining replacement value)) + else (candidate, value)) + fields + in + let fields = + if !found then fields + else + match remaining with + | [] -> fields @ [ (name, replacement) ] + | _ -> failwith "mutation cannot add a nested missing field" + in + `Assoc fields + | Index index :: remaining, `List values -> + `List + (List.mapi + (fun candidate value -> + if candidate = index then set_path remaining replacement value + else value) + values) + | _ -> failwith "mutation path cannot be replaced" + +let rec remove_path path json = + match (path, json) with + | [ Field name ], `Assoc fields -> + `Assoc + (List.filter + (fun (candidate, _) -> not (String.equal name candidate)) + fields) + | Field name :: remaining, `Assoc fields -> + `Assoc + (List.map + (fun (candidate, value) -> + if String.equal candidate name then + (candidate, remove_path remaining value) + else (candidate, value)) + fields) + | Index index :: remaining, `List values -> + `List + (List.mapi + (fun candidate value -> + if candidate = index then remove_path remaining value else value) + values) + | _ -> failwith "mutation path cannot be removed" + +let apply_mutation document mutation = + let operation = string_field "op" mutation in + let path = field "path" mutation |> path_of_yojson in + match operation with + | "remove" -> remove_path path document + | "add" | "replace" -> set_path path (field "value" mutation) document + | "append_copy" -> ( + let index = + match field "index" mutation with + | `Int value -> value + | _ -> failwith "append_copy index must be an integer" + in + match find_path path document with + | `List values -> + set_path path (`List (values @ [ List.nth values index ])) document + | _ -> failwith "append_copy target must be an array") + | value -> failwith ("unsupported mutation operation " ^ value) + +let apply_mutations case document = + List.fold_left apply_mutation document (list_field "mutations" case) + +let read_json relative = Yojson.Safe.from_file (contract_path relative) + +let read_jsonl relative = + In_channel.with_open_bin (contract_path relative) In_channel.input_lines + |> List.filter (fun line -> not (String.equal line "")) + |> List.map Yojson.Safe.from_string + +let select_record case records = + match optional_field "record" case with + | Some (`Int line_number) -> List.nth records (line_number - 1) + | _ -> failwith "conformance case must select a record" + +let extract case document = + match optional_field "extract" case with + | Some path -> find_path (path_of_yojson path) document + | None -> document + +let materialize_case case = + let name = "conformance/" ^ string_field "name" case in + match optional_field "kind" case with + | Some (`String "scenario") -> + { + name; + boundary = Batch; + input = + read_json (string_field "source" case) + |> apply_mutations case |> Yojson.Safe.to_string; + } + | Some (`String "scenario_stream") -> + let records = read_jsonl (string_field "source" case) in + let records = + match optional_field "record" case with + | None -> records + | Some (`Int line_number) -> + List.mapi + (fun index record -> + if index = line_number - 1 then apply_mutations case record + else record) + records + | _ -> failwith "stream record must be an integer" + in + { + name; + boundary = Stream; + input = + ( records |> List.map Yojson.Safe.to_string |> String.concat "\n" + |> fun document -> document ^ "\n" ); + } + | Some (`String "strategy_response") -> + { + name; + boundary = Strategy; + input = + read_jsonl (string_field "source" case) + |> select_record case |> extract case |> apply_mutations case + |> Yojson.Safe.to_string; + } + | Some (`String kind) -> failwith ("unsupported conformance kind " ^ kind) + | Some _ -> failwith "conformance kind must be a string" + | None -> + let document = + match optional_field "instance" case with + | Some value -> value + | None -> + read_jsonl (string_field "source" case) + |> select_record case |> extract case + in + { + name; + boundary = + (if + String.starts_with ~prefix:"strategy-message" + (string_field "artifact" case) + then Strategy + else Json); + input = apply_mutations case document |> Yojson.Safe.to_string; + } + +let rec fixture_files directory = + Sys.readdir directory |> Array.to_list |> List.sort String.compare + |> List.concat_map (fun name -> + let path = Filename.concat directory name in + if Sys.is_directory path then fixture_files path else [ path ]) + +let relative_to_contracts path = + let prefix = contracts ^ Filename.dir_sep in + String.sub path (String.length prefix) + (String.length path - String.length prefix) + +let canonical_seeds () = + fixture_files contracts + |> List.filter (fun path -> + String.split_on_char '/' path |> List.mem "fixtures") + |> List.concat_map (fun path -> + let relative = relative_to_contracts path in + let contents = In_channel.with_open_bin path In_channel.input_all in + if String.ends_with ~suffix:".scenario.json" path then + [ { name = relative; boundary = Batch; input = contents } ] + else if String.ends_with ~suffix:".scenario.jsonl" path then + [ { name = relative; boundary = Stream; input = contents } ] + else if String.ends_with ~suffix:".strategy.jsonl" path then + contents |> String.split_on_char '\n' + |> List.filter (fun line -> not (String.equal line "")) + |> List.mapi (fun index line -> + let record = Yojson.Safe.from_string line in + [ + { + name = Printf.sprintf "%s:%d" relative (index + 1); + boundary = Json; + input = line; + }; + ] + @ + match optional_field "message" record with + | Some message -> + [ + { + name = Printf.sprintf "%s:%d/message" relative (index + 1); + boundary = Strategy; + input = Yojson.Safe.to_string message; + }; + ] + | None -> []) + |> List.flatten + else + contents |> String.split_on_char '\n' + |> List.filter (fun line -> not (String.equal line "")) + |> List.mapi (fun index line -> + { + name = Printf.sprintf "%s:%d" relative (index + 1); + boundary = Json; + input = line; + })) + +let conformance_seeds () = + match read_json "conformance/cases.json" with + | `Assoc fields -> + let cases name = + match List.assoc name fields with + | `List values -> values + | _ -> failwith (name ^ " must be an array") + in + List.map materialize_case (cases "cases" @ cases "schema_only_cases") + | _ -> failwith "conformance cases must be an object" + +let primitive_seeds = + [ + { + name = "timestamp/canonical"; + boundary = Timestamp; + input = "2026-01-02T14:30:00Z"; + }; + { + name = "timestamp/offset"; + boundary = Timestamp; + input = "2026-01-02T14:30:00-05:00"; + }; + { + name = "timestamp/leap-second"; + boundary = Timestamp; + input = "2026-01-02T14:30:60Z"; + }; + { name = "decimal/zero"; boundary = Decimal; input = "0" }; + { name = "decimal/signed"; boundary = Decimal; input = "-1.000001" }; + { name = "decimal/noncanonical"; boundary = Decimal; input = "01.0" }; + { + name = "identifier/canonical"; + boundary = Identifier; + input = "fuzz-id_01"; + }; + { name = "identifier/empty"; boundary = Identifier; input = "" }; + { name = "identifier/control"; boundary = Identifier; input = "bad\000id" }; + ] + +let deep_json depth = String.make depth '[' ^ "null" ^ String.make depth ']' + +let hostile_seeds () = + let huge_json_string = "\"" ^ String.make 1_048_577 'x' ^ "\"" in + let json_inputs = + [ + ("malformed-utf8", String.make 1 (Char.chr 255)); + ("deep-nesting", deep_json 512); + ("huge-token", huge_json_string); + ( "duplicate-key", + "{\"contract_version\":\"1\",\"contract_version\":\"1\"}" ); + ("truncation", "{\"contract_version\":"); + ] + in + List.concat_map + (fun boundary -> + List.map + (fun (name, input) -> { name = "hostile/" ^ name; boundary; input }) + json_inputs) + [ Batch; Stream; Strategy; Json ] + @ [ + { + name = "timestamp/huge"; + boundary = Timestamp; + input = String.make 4096 '9'; + }; + { + name = "decimal/huge"; + boundary = Decimal; + input = String.make 4096 '9'; + }; + { + name = "identifier/huge"; + boundary = Identifier; + input = String.make 4096 'x'; + }; + ] + +let with_stream document function_ = + let path = Filename.temp_file "trading-engine-fuzz" ".jsonl" in + Fun.protect + ~finally:(fun () -> if Sys.file_exists path then Sys.remove path) + (fun () -> + Out_channel.with_open_bin path (fun channel -> + output_string channel document); + function_ path) + +let expected_sequence document = + try + match Yojson.Safe.from_string document with + | `Assoc fields -> ( + match List.assoc_opt "strategy_sequence" fields with + | Some (`String value) -> + Option.value (Int64.of_string_opt value) ~default:1L + | _ -> 1L) + | _ -> 1L + with Yojson.Json_error _ -> 1L + +let exercise boundary input = + match boundary with + | Batch -> ignore (T.Scenario.of_string input) + | Stream -> + with_stream input (fun path -> + ignore + (T.Scenario_stream.fold_file path + ~init:(fun _ -> Ok ()) + ~step:(fun () _ -> Ok ()) + ~finish:(fun () ~slice_count:_ -> Ok ()))) + | Strategy -> + ignore + (T.Strategy_protocol.response_of_string + ~expected_sequence:(expected_sequence input) input) + | Timestamp -> ignore (T.Codec.ptime_of_string input) + | Decimal -> + ignore (T.Scalar.Price.of_decimal_string input); + ignore (T.Scalar.Quantity.of_decimal_string input); + ignore (T.Scalar.Money.of_decimal_string input); + ignore (T.Scalar.Weight.of_decimal_string input); + ignore (T.Scalar.Ratio.of_decimal_string input) + | Identifier -> + ignore (T.Id.Run.of_string input); + ignore (T.Id.Instrument.of_string input); + ignore (T.Id.Order.of_string input); + ignore (T.Id.Fill.of_string input); + ignore (T.Id.Strategy.of_string input); + ignore (T.Id.Event.of_string input); + ignore (T.Id.Corporate_action.of_string input) + | Json -> ( + try ignore (Yojson.Safe.from_string input) + with Yojson.Json_error _ -> ()) + +let hex_prefix input = + let length = Int.min 128 (String.length input) in + String.init (length * 2) (fun index -> + let byte = Char.code input.[index / 2] in + let nibble = if index mod 2 = 0 then byte lsr 4 else byte land 15 in + "0123456789abcdef".[nibble]) + +let run seed = + try exercise seed.boundary seed.input + with exception_ -> + Printf.eprintf + "fuzz failure boundary=%s name=%s bytes=%d prefix=%s exception=%s\n%!" + (boundary_name seed.boundary) + seed.name (String.length seed.input) (hex_prefix seed.input) + (Printexc.to_string exception_); + exit 1 + +let mutate state input = + let length = String.length input in + match Random.State.int state 5 with + | 0 -> + if length = 0 then input + else String.sub input 0 (Random.State.int state length) + | 1 -> + if length = 0 then String.make 1 (Char.chr (Random.State.int state 256)) + else + let bytes = Bytes.of_string input in + let index = Random.State.int state length in + Bytes.set bytes index (Char.chr (Random.State.int state 256)); + Bytes.unsafe_to_string bytes + | 2 -> + let index = Random.State.int state (length + 1) in + let byte = String.make 1 (Char.chr (Random.State.int state 256)) in + String.sub input 0 index ^ byte ^ String.sub input index (length - index) + | 3 -> + if length = 0 then input + else + let start = Random.State.int state length in + let count = 1 + Random.State.int state (length - start) in + String.sub input 0 start + ^ String.sub input (start + count) (length - start - count) + | _ -> + if length = 0 then input + else + let start = Random.State.int state length in + let maximum = Int.min 64 (length - start) in + let count = 1 + Random.State.int state maximum in + let insertion = Random.State.int state (length + 1) in + String.sub input 0 insertion + ^ String.sub input start count + ^ String.sub input insertion (length - insertion) + +let () = + let random_seed = ref 20_260_821 in + let cases = ref 256 in + Arg.parse + [ + ("--seed", Arg.Set_int random_seed, "deterministic random seed"); + ("--cases", Arg.Set_int cases, "number of byte-mutation cases"); + ] + (fun argument -> raise (Arg.Bad ("unexpected argument " ^ argument))) + "fuzz_protocol [--seed INTEGER] [--cases INTEGER]"; + if !cases < 0 then raise (Arg.Bad "--cases must be nonnegative"); + let corpus = canonical_seeds () @ conformance_seeds () @ primitive_seeds in + List.iter run corpus; + List.iter run (hostile_seeds ()); + let state = Random.State.make [| !random_seed |] in + for index = 1 to !cases do + let source = + List.nth corpus (Random.State.int state (List.length corpus)) + in + run + { + source with + name = Printf.sprintf "mutation/%d/%s" index source.name; + input = mutate state source.input; + } + done; + Printf.printf "fuzz seed=%d cases=%d corpus=%d status=ok\n" !random_seed + !cases (List.length corpus) diff --git a/test/test_accounting.ml b/test/test_accounting.ml index 2a3d948..e611b32 100644 --- a/test/test_accounting.ml +++ b/test/test_accounting.ml @@ -202,6 +202,85 @@ let risk_reserves_working_sells () = "second sell oversubscribes holdings" true (Result.is_error (risk_check risk ~account ~oms second)) +let add_working_order oms ~id ~accepted_sequence request = + T.Oms.accept oms ~id:(order_id id) + ~created_event_id:(event_id (id ^ "-event")) + ~accepted_sequence + ~created_at:(timestamp "2026-01-02T21:00:02Z") + ~eligible_after_slice_sequence:1L request + |> ok |> fst + +let risk_rejects_self_crossing_orders () = + let account = test_account () in + let configured = risk () in + let buy = request ~side:T.Order.Buy ~quantity_value:"4" () in + let buy_oms = + add_working_order T.Oms.empty ~id:"buy" ~accepted_sequence:1L buy + in + let sell = request ~side:T.Order.Sell ~quantity_value:"1" () in + Alcotest.(check string) + "sell against working buy" + "order would self-cross an active opposite-side order" + (risk_check configured ~account ~oms:buy_oms sell |> error); + let sell_oms = + add_working_order T.Oms.empty ~id:"sell" ~accepted_sequence:1L sell + in + Alcotest.(check string) + "buy against working sell" + "order would self-cross an active opposite-side order" + (risk_check configured ~account ~oms:sell_oms buy |> error) + +let risk_reserves_partial_order_remainders () = + let account = test_account () in + let configured = risk ~max_long:"5" () in + let oms, order = + oms_with_order (request ~side:T.Order.Buy ~quantity_value:"10" ()) + in + let partial = fill ~quantity_value:"6" order in + let oms = + match T.Oms.apply_fill oms partial with + | Ok (oms, T.Oms.Applied _) -> oms + | Ok (_, T.Oms.Duplicate) -> Alcotest.fail "expected an applied fill" + | Error message -> Alcotest.fail message + in + Alcotest.(check (result unit string)) + "one unit fits after partial fill" (Ok ()) + (risk_check configured ~account ~oms + (request ~side:T.Order.Buy ~quantity_value:"1" ())); + Alcotest.(check string) + "two units exceed the reserved long limit" + "position would exceed the instrument maximum long position" + (risk_check configured ~account ~oms + (request ~side:T.Order.Buy ~quantity_value:"2" ()) + |> error) + +let risk_values_directional_reservations_without_netting () = + let primary = instrument () in + let hedge = instrument ~id:"hedge" ~symbol:"HEDGE" () in + let configured = risk ~instruments:[ primary; hedge ] ~max_gross:"1000" () in + let account = test_account () in + let primary_id = instrument_id "test-equity" in + let buy = + request ~instrument:primary_id ~side:T.Order.Buy ~quantity_value:"8" () + in + let sell = + request ~instrument:primary_id ~side:T.Order.Sell ~quantity_value:"8" () + in + let oms = + add_working_order T.Oms.empty ~id:"buy" ~accepted_sequence:1L buy + |> fun oms -> add_working_order oms ~id:"sell" ~accepted_sequence:2L sell + in + let result = + T.Risk.check configured ~account ~oms + ~marks:[ (primary_id, price "100"); (instrument_id "hedge", price "100") ] + ~fx_rates:[ ("USD", price "1") ] + (request ~instrument:(instrument_id "hedge") ~side:T.Order.Buy + ~quantity_value:"4" ()) + in + Alcotest.(check string) + "opposing reservations retain their directional exposure" + "portfolio would exceed maximum gross exposure" (result |> error) + let accounting_identity_property = let open QCheck2 in let generator = @@ -265,5 +344,11 @@ let tests = fills_settle_to_explicit_margin_cash; Alcotest.test_case "risk reserves working sells" `Quick risk_reserves_working_sells; + Alcotest.test_case "risk rejects self-crossing orders" `Quick + risk_rejects_self_crossing_orders; + Alcotest.test_case "risk reserves partial-order remainders" `Quick + risk_reserves_partial_order_remainders; + Alcotest.test_case "risk values directional reservations without netting" + `Quick risk_values_directional_reservations_without_netting; QCheck_alcotest.to_alcotest ~speed_level:`Quick accounting_identity_property; ] diff --git a/test/test_benchmark_replay.py b/test/test_benchmark_replay.py new file mode 100644 index 0000000..2ef0a75 --- /dev/null +++ b/test/test_benchmark_replay.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Focused contract tests for the replay benchmark harness.""" + +from __future__ import annotations + +import importlib.util +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "benchmark_replay", ROOT / "bench/benchmark_replay.py" +) +assert SPEC is not None and SPEC.loader is not None +BENCHMARK = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = BENCHMARK +SPEC.loader.exec_module(BENCHMARK) + + +class BenchmarkReplayTest(unittest.TestCase): + def test_generated_batch_has_requested_dimensions(self) -> None: + case = BENCHMARK.BenchmarkCase("unit", "batch", 3, 4, 2) + scenario = BENCHMARK.build_scenario(case) + + self.assertEqual(3, len(scenario["instruments"])) + self.assertEqual(4, len(scenario["slices"])) + self.assertTrue(all(len(item["bars"]) == 3 for item in scenario["slices"])) + self.assertEqual(2, len(scenario["schedule"][0]["intents"])) + self.assertTrue( + all( + intent["limit_price"] == "1" + for intent in scenario["schedule"][0]["intents"] + ) + ) + + def test_stream_preserves_slices_and_scheduled_intents(self) -> None: + case = BENCHMARK.BenchmarkCase("unit", "stream", 2, 3, 5) + records = BENCHMARK.stream_records(BENCHMARK.build_scenario(case)) + + self.assertEqual("scenario_header", records[0]["record_type"]) + self.assertEqual("scenario_end", records[-1]["record_type"]) + self.assertEqual("3", records[-1]["payload"]["slice_count"]) + self.assertEqual(5, len(records[1]["payload"]["intents"])) + self.assertEqual([], records[2]["payload"]["intents"]) + + def test_summary_parser_reads_batch_and_stream_counts(self) -> None: + self.assertEqual( + (321, 17), + BENCHMARK.parse_summary( + "run=benchmark audits=321 orders=17 active=17 filled=0 rejected=0\n" + ), + ) + + def test_tolerance_comparison_checks_both_metric_directions(self) -> None: + result = { + "median_wall_seconds": 1.31, + "median_peak_rss_kib": 120.0, + "median_events_per_second": 79.0, + "median_artifact_bytes_per_second": 90.0, + } + baseline = { + "metrics": { + "median_wall_seconds": 1.0, + "median_peak_rss_kib": 100.0, + "median_events_per_second": 100.0, + "median_artifact_bytes_per_second": 100.0, + }, + "tolerances": { + "median_wall_seconds": 0.30, + "median_peak_rss_kib": 0.25, + "median_events_per_second": 0.20, + "median_artifact_bytes_per_second": 0.20, + }, + } + + regressions = BENCHMARK.find_regressions(result, baseline) + + self.assertEqual(2, len(regressions)) + self.assertTrue(any("wall" in regression for regression in regressions)) + self.assertTrue(any("events" in regression for regression in regressions)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_boundary_failures.ml b/test/test_boundary_failures.ml new file mode 100644 index 0000000..6cae1b4 --- /dev/null +++ b/test/test_boundary_failures.ml @@ -0,0 +1,395 @@ +open Test_support +module T = Trading_engine + +exception Injected_failure of string + +let remove_if_exists path = if Sys.file_exists path then Sys.remove path + +let with_absent_path suffix test = + let path = Filename.temp_file "trading-engine-boundary" suffix in + Sys.remove path; + Fun.protect + ~finally:(fun () -> + remove_if_exists path; + remove_if_exists (path ^ ".partial"); + remove_if_exists (path ^ ".partial.cleanup")) + (fun () -> test path) + +let injected_effects target = + let triggered = ref false in + let perform : type result. + T.Boundary_effects.operation -> (unit -> result) -> result = + fun operation run -> + if (not !triggered) && T.Boundary_effects.stage operation = target then ( + triggered := true; + match operation with + | T.Boundary_effects.Write_artifact { channel; contents } -> + output_substring channel contents 0 + (Int.min 8 (String.length contents)); + raise (Injected_failure (T.Boundary_effects.stage_to_string target)) + | T.Boundary_effects.Publish_artifact { final_path; _ } -> + Out_channel.with_open_bin final_path (fun channel -> + output_string channel "rival\n"); + run () + | _ -> + raise (Injected_failure (T.Boundary_effects.stage_to_string target))) + else run () + in + ({ T.Boundary_effects.perform }, triggered) + +let audit = + T.Audit.create ~contract_version:T.Contract.version ~engine_sequence:1L + ~causation_ids:[] + ~run_id:(run_id "boundary-failure") + ~recorded_at:(timestamp "2026-01-02T21:00:02Z") + (T.Audit.Run_started + { scenario_sha256; execution_model = "completed_bar_v1" }) + +module type Artifact_writer = sig + type t + + val create : + effects:T.Boundary_effects.t -> string -> (t, T.Diagnostic.t) result + + val append : t -> (unit, T.Diagnostic.t) result + val commit : t -> (unit, T.Diagnostic.t) result + val close_preserving_partial : t -> unit +end + +module Journal_writer = struct + type t = T.Journal.t + + let create ~effects path = T.Journal.create ~effects path + let append journal = T.Journal.append journal audit + let commit = T.Journal.commit + let close_preserving_partial = T.Journal.close_preserving_partial +end + +module Transcript_writer = struct + type t = T.Strategy_transcript.t + + let create ~effects path = T.Strategy_transcript.create ~effects path + + let append transcript = + T.Strategy_transcript.append transcript + ~direction:T.Strategy_protocol.Engine_to_strategy + (T.Strategy_protocol.shutdown_message ~sequence:1L) + + let commit = T.Strategy_transcript.commit + let close_preserving_partial = T.Strategy_transcript.close_preserving_partial +end + +let artifact_stages = + T.Boundary_effects. + [ + Artifact_create; + Artifact_write; + Artifact_flush; + Artifact_close; + Artifact_publish; + Artifact_rename; + Artifact_cleanup; + ] + +let exercise_artifact_failure (type writer) writer_name + (module Writer : Artifact_writer with type t = writer) stage = + with_absent_path ".jsonl" @@ fun final_path -> + let partial_path = final_path ^ ".partial" in + let effects, triggered = injected_effects stage in + let result = + match Writer.create ~effects final_path with + | Error _ as error -> error + | Ok writer -> + let result = + match stage with + | T.Boundary_effects.Artifact_write | Artifact_flush -> + Writer.append writer + | Artifact_close | Artifact_publish | Artifact_rename + | Artifact_cleanup -> ( + match Writer.append writer with + | Error _ as error -> error + | Ok () -> Writer.commit writer) + | Artifact_create -> Alcotest.fail "create failure was not injected" + | Artifact_sync_file | Artifact_restore | Artifact_sync_directory -> + Alcotest.fail "expected a lifecycle failure" + | Process_spawn | Process_exchange | Process_terminate | Process_reap + -> + Alcotest.fail "expected an artifact stage" + in + Writer.close_preserving_partial writer; + result + in + let diagnostic = error result in + Alcotest.(check bool) "fault triggered" true !triggered; + Alcotest.(check string) + "artifact diagnostic" "artifact.io" + (T.Diagnostic.code_to_string diagnostic.code); + let expected_final = + stage = T.Boundary_effects.Artifact_publish + || stage = T.Boundary_effects.Artifact_rename + || stage = T.Boundary_effects.Artifact_cleanup + in + let expected_partial = stage <> T.Boundary_effects.Artifact_create in + Alcotest.(check bool) + (writer_name ^ " final invariant") + expected_final + (Sys.file_exists final_path); + Alcotest.(check bool) + (writer_name ^ " partial invariant") + expected_partial + (Sys.file_exists partial_path); + Alcotest.(check bool) + (writer_name ^ " cleanup path invariant") + false + (Sys.file_exists (partial_path ^ ".cleanup")); + if stage = T.Boundary_effects.Artifact_write then + Alcotest.(check int64) + "short write retained" 8L + (In_channel.with_open_bin partial_path In_channel.length); + if stage = T.Boundary_effects.Artifact_publish then + Alcotest.(check string) + "publication rival preserved" "rival\n" + (In_channel.with_open_bin final_path In_channel.input_all); + if + stage = T.Boundary_effects.Artifact_rename + || stage = T.Boundary_effects.Artifact_cleanup + then + Alcotest.(check string) + "published and partial bytes agree" + (In_channel.with_open_bin partial_path In_channel.input_all) + (In_channel.with_open_bin final_path In_channel.input_all) + +let artifact_cases writer_name writer = + List.map + (fun stage -> + Alcotest.test_case + (writer_name ^ " " ^ T.Boundary_effects.stage_to_string stage) + `Quick + (fun () -> exercise_artifact_failure writer_name writer stage)) + artifact_stages + +let nth_failure target occurrence = + let seen = ref 0 in + let perform : type result. + T.Boundary_effects.operation -> (unit -> result) -> result = + fun operation run -> + if T.Boundary_effects.stage operation = target then ( + seen := !seen + 1; + if !seen = occurrence then + raise (Injected_failure (T.Boundary_effects.stage_to_string target))); + run () + in + ({ T.Boundary_effects.perform }, seen) + +let exercise_transaction_failure stage occurrence = + with_absent_path ".journal.jsonl" @@ fun journal_path -> + with_absent_path ".strategy.jsonl" @@ fun transcript_path -> + let effects, seen = nth_failure stage occurrence in + let journal = + T.Artifact_writer.create ~effects ~label:"journal" journal_path |> ok + in + let transcript = + T.Artifact_writer.create ~effects ~label:"strategy transcript" + transcript_path + |> ok + in + T.Artifact_writer.append journal "journal\n" |> ok; + T.Artifact_writer.append transcript "transcript\n" |> ok; + let diagnostic = T.Artifact_writer.commit [ journal; transcript ] |> error in + Alcotest.(check bool) "target occurrence reached" true (!seen >= occurrence); + Alcotest.(check string) + "artifact diagnostic" "artifact.io" + (T.Diagnostic.code_to_string diagnostic.code); + let finals_exist = + stage = T.Boundary_effects.Artifact_rename + || stage = T.Boundary_effects.Artifact_cleanup + in + List.iter + (fun path -> + Alcotest.(check bool) + "final-set invariant" finals_exist (Sys.file_exists path); + Alcotest.(check bool) + "partial-set invariant" true + (Sys.file_exists (path ^ ".partial")); + Alcotest.(check bool) + "cleanup-set invariant" false + (Sys.file_exists (path ^ ".partial.cleanup"))) + [ journal_path; transcript_path ]; + if finals_exist then + List.iter + (fun path -> + Alcotest.(check string) + "final and restored partial agree" + (In_channel.with_open_bin path In_channel.input_all) + (In_channel.with_open_bin (path ^ ".partial") In_channel.input_all)) + [ journal_path; transcript_path ] + +let transaction_cases = + List.concat_map + (fun stage -> + List.map + (fun occurrence -> + Alcotest.test_case + (Printf.sprintf "transaction %s %d" + (T.Boundary_effects.stage_to_string stage) + occurrence) + `Quick + (fun () -> exercise_transaction_failure stage occurrence)) + [ 1; 2 ]) + T.Boundary_effects. + [ Artifact_close; Artifact_publish; Artifact_rename; Artifact_cleanup ] + +let create_durable_artifacts effects journal_path transcript_path = + let create label path = + T.Artifact_writer.create ~effects ~durability:T.Artifact_writer.Durable + ~label path + |> ok + in + let journal = create "journal" journal_path in + let transcript = create "strategy transcript" transcript_path in + T.Artifact_writer.append journal "journal\n" |> ok; + T.Artifact_writer.append transcript "transcript\n" |> ok; + (journal, transcript) + +let exercise_durability_failure stage occurrence = + with_absent_path ".durable-journal.jsonl" @@ fun journal_path -> + with_absent_path ".durable-strategy.jsonl" @@ fun transcript_path -> + let effects, seen = nth_failure stage occurrence in + let journal, transcript = + create_durable_artifacts effects journal_path transcript_path + in + let diagnostic = T.Artifact_writer.commit [ journal; transcript ] |> error in + Alcotest.(check bool) "target occurrence reached" true (!seen >= occurrence); + Alcotest.(check string) + "durability diagnostic" "artifact.io" + (T.Diagnostic.code_to_string diagnostic.code); + let finals_exist = + stage = T.Boundary_effects.Artifact_sync_directory && occurrence = 2 + in + List.iter + (fun path -> + Alcotest.(check bool) + "durable final-set invariant" finals_exist (Sys.file_exists path); + Alcotest.(check bool) + "durable partial-set invariant" true + (Sys.file_exists (path ^ ".partial")); + Alcotest.(check bool) + "durable cleanup-set invariant" false + (Sys.file_exists (path ^ ".partial.cleanup"))) + [ journal_path; transcript_path ] + +let durable_transaction_succeeds () = + with_absent_path ".durable-journal.jsonl" @@ fun journal_path -> + with_absent_path ".durable-strategy.jsonl" @@ fun transcript_path -> + let journal, transcript = + create_durable_artifacts T.Boundary_effects.direct journal_path + transcript_path + in + T.Artifact_writer.commit [ journal; transcript ] |> ok; + List.iter + (fun path -> + Alcotest.(check bool) "durable final exists" true (Sys.file_exists path); + Alcotest.(check bool) + "durable partial removed" false + (Sys.file_exists (path ^ ".partial")); + Alcotest.(check bool) + "durable cleanup path removed" false + (Sys.file_exists (path ^ ".partial.cleanup")); + Alcotest.(check int) + "private artifact mode" 0o600 + ((Unix.stat path).st_perm land 0o777)) + [ journal_path; transcript_path ] + +let durability_cases = + [ + Alcotest.test_case "durable file sync 1" `Quick (fun () -> + exercise_durability_failure T.Boundary_effects.Artifact_sync_file 1); + Alcotest.test_case "durable file sync 2" `Quick (fun () -> + exercise_durability_failure T.Boundary_effects.Artifact_sync_file 2); + Alcotest.test_case "durable publication directory sync" `Quick (fun () -> + exercise_durability_failure T.Boundary_effects.Artifact_sync_directory 1); + Alcotest.test_case "durable cleanup directory sync" `Quick (fun () -> + exercise_durability_failure T.Boundary_effects.Artifact_sync_directory 2); + Alcotest.test_case "durable transaction succeeds" `Quick + durable_transaction_succeeds; + ] + +let initialization () = + let instrument = instrument () in + T.Strategy_protocol. + { + scenario_contract_version = T.Contract.version; + scenario_sha256; + metadata = `Assoc [ ("experiment", `String "boundary-failure") ]; + run_id = run_id "boundary-failure"; + base_currency = "USD"; + initial_portfolio = initial_portfolio (); + instruments = [ instrument ]; + venue_calendars = []; + risk = risk ~instruments:[ instrument ] (); + execution_model = T.Execution_model.find "completed_bar_v1" |> ok; + execution = execution (); + financing = financing_policy (); + settlement = settlement_policy (); + } + +let process_stages = + T.Boundary_effects. + [ Process_spawn; Process_exchange; Process_terminate; Process_reap ] + +let exercise_process_failure stage = + with_absent_path ".strategy.jsonl" @@ fun final_path -> + let effects, triggered = injected_effects stage in + let result = + Eio_main.run @@ fun env -> + T.Strategy_process.with_session ~effects ~env + ~command:[ "./fake_strategy.py"; "success" ] + ~timeout:1.0 ~transcript_path:final_path + ~initialization:(initialization ()) (fun _ -> Ok ()) + in + let diagnostic = error result in + Alcotest.(check bool) "fault triggered" true !triggered; + Alcotest.(check string) + "process diagnostic" "strategy.process" + (T.Diagnostic.code_to_string diagnostic.code); + Alcotest.(check bool) "final absent" false (Sys.file_exists final_path); + Alcotest.(check bool) + "partial retained" true + (Sys.file_exists (final_path ^ ".partial")) + +let process_cases = + List.map + (fun stage -> + Alcotest.test_case (T.Boundary_effects.stage_to_string stage) `Quick + (fun () -> exercise_process_failure stage)) + process_stages + +let artifact_records_are_bounded () = + with_absent_path ".bounded.jsonl" @@ fun final_path -> + let writer = + T.Artifact_writer.create ~label:"bounded artifact" final_path |> ok + in + T.Artifact_writer.append writer + (String.make T.Resource_limits.artifact_record_bytes 'x') + |> ok; + let diagnostic = + T.Artifact_writer.append writer + (String.make (T.Resource_limits.artifact_record_bytes + 1) 'x') + |> error + in + Alcotest.(check string) + "artifact limit code" "resource.limit" + (T.Diagnostic.code_to_string diagnostic.code); + Alcotest.(check int) + "oversized record was not written" T.Resource_limits.artifact_record_bytes + (In_channel.with_open_bin (final_path ^ ".partial") in_channel_length); + T.Artifact_writer.close_preserving_partial writer + +let tests = + artifact_cases "journal" (module Journal_writer) + @ artifact_cases "transcript" (module Transcript_writer) + @ transaction_cases @ durability_cases @ process_cases + @ [ + Alcotest.test_case "artifact records are bounded" `Quick + artifact_records_are_bounded; + ] diff --git a/test/test_checkpoint4.ml b/test/test_checkpoint4.ml index 554562c..e8f0852 100644 --- a/test/test_checkpoint4.ml +++ b/test/test_checkpoint4.ml @@ -2,37 +2,6 @@ open Test_support module T = Trading_engine module Runner = T.Engine.Make (T.Scripted_strategy) -module Margin_observing_strategy = struct - type state = { saw_liquidation_update : bool } - - let name = "margin-observer" - let initial = { saw_liquidation_update = false } - - let on_event state _context event = - match event with - | T.Strategy.Market_slice_closed market_slice - when Int64.equal market_slice.T.Market_slice.slice_sequence 1L -> - ( state, - [ - T.Strategy.Target_quantities - [ - T.Strategy. - { - instrument_id = instrument_id "test-equity"; - quantity = quantity "15"; - }; - ]; - ] ) - | T.Strategy.Order_updated order - when order.T.Order.request.origin = T.Order.Margin_liquidation -> - ({ saw_liquidation_update = true }, []) - | T.Strategy.Market_slice_closed _ | T.Strategy.Fill_received _ - | T.Strategy.Order_updated _ | T.Strategy.Intent_rejected _ -> - (state, []) -end - -module Margin_runner = T.Engine.Make (Margin_observing_strategy) - let euro_instrument () = instrument ~id:"euro-equity" ~symbol:"EURO" ~currency:"EUR" ~lot_size:"0.001" () @@ -51,6 +20,7 @@ let multi_currency_fractional_accounting () = T.Fill.create ~id:(fill_id "euro-fill") ~order_id:order.id ~instrument_id:euro.id ~quote_currency:"EUR" ~side:T.Order.Buy ~quantity:(quantity "1.5") ~price:(price "20") ~fee:(money "0.5") + ~fee_components:[] ~executed_at:(timestamp "2026-01-03T14:30:00Z") ~slice_sequence:2L |> ok @@ -146,7 +116,7 @@ let split_adjusts_working_order () = let config = engine_config () in let state = Runner.create ~run_id:(run_id "split-order") ~scenario_sha256 ~config - ~initial_cash:[ ("USD", money "10000") ] + ~initial_portfolio:(initial_portfolio ~cash:[ ("USD", money "10000") ] ()) ~strategy_state |> ok in @@ -159,7 +129,8 @@ let split_adjusts_working_order () = (match order.request.kind with | T.Order.Limit limit -> Alcotest.check price_testable "limit price halves" (price "25") limit - | T.Order.Market -> Alcotest.fail "expected adjusted limit order"); + | T.Order.Market | T.Order.Stop _ | T.Order.Stop_limit _ -> + Alcotest.fail "expected adjusted limit order"); Alcotest.(check (list string)) "causal adjustment events" [ "market_slice_received"; "split_applied"; "order_adjusted"; "valuation" ] @@ -189,7 +160,7 @@ let split_caps_adjusted_market_fill () = Runner.create ~run_id:(run_id "split-market-cap") ~scenario_sha256 ~config - ~initial_cash:[ ("USD", money "10000") ] + ~initial_portfolio:(initial_portfolio ~cash:[ ("USD", money "10000") ] ()) ~strategy_state |> ok in @@ -220,17 +191,27 @@ let split_caps_adjusted_market_fill () = List.find_map (fun event -> match event.T.Audit.event with - | T.Audit.Margin_limited { requested_quantity; permitted_quantity; _ } - -> - Some (requested_quantity, permitted_quantity) + | T.Audit.Fill_clipped + { + proposed_quantity; + permitted_quantity; + limit = T.Risk.Maximum_order_quantity threshold; + _; + } -> + Some (proposed_quantity, permitted_quantity, threshold) | _ -> None) events |> Option.get in Alcotest.check quantity_testable "adjusted proposal" (quantity "20") - (fst limited); + (let proposed, _, _ = limited in + proposed); Alcotest.check quantity_testable "maximum permitted fill" (quantity "10") - (snd limited) + (let _, permitted, _ = limited in + permitted); + Alcotest.check quantity_testable "maximum order threshold" (quantity "10") + (let _, _, threshold = limited in + threshold) let split_caps_partially_filled_limit_remainder () = let strategy_state = @@ -251,7 +232,7 @@ let split_caps_partially_filled_limit_remainder () = Runner.create ~run_id:(run_id "split-partial-limit") ~scenario_sha256 ~config - ~initial_cash:[ ("USD", money "10000") ] + ~initial_portfolio:(initial_portfolio ~cash:[ ("USD", money "10000") ] ()) ~strategy_state |> ok in @@ -285,17 +266,27 @@ let split_caps_partially_filled_limit_remainder () = List.find_map (fun event -> match event.T.Audit.event with - | T.Audit.Margin_limited { requested_quantity; permitted_quantity; _ } - -> - Some (requested_quantity, permitted_quantity) + | T.Audit.Fill_clipped + { + proposed_quantity; + permitted_quantity; + limit = T.Risk.Maximum_order_quantity threshold; + _; + } -> + Some (proposed_quantity, permitted_quantity, threshold) | _ -> None) events |> Option.get in Alcotest.check quantity_testable "adjusted partial proposal" (quantity "12") - (fst limited); + (let proposed, _, _ = limited in + proposed); Alcotest.check quantity_testable "bounded partial fill" (quantity "10") - (snd limited); + (let _, permitted, _ = limited in + permitted); + Alcotest.check quantity_testable "maximum order threshold" (quantity "10") + (let _, _, threshold = limited in + threshold); let state, _ = Runner.process_slice state (market_slice ~bars:[ adjusted_bar ] 4L) |> ok in @@ -327,7 +318,7 @@ let reverse_split_restores_order_below_maximum () = Runner.create ~run_id:(run_id "reverse-split-order") ~scenario_sha256 ~config - ~initial_cash:[ ("USD", money "10000") ] + ~initial_portfolio:(initial_portfolio ~cash:[ ("USD", money "10000") ] ()) ~strategy_state |> ok in @@ -365,102 +356,10 @@ let reverse_split_restores_order_below_maximum () = (List.exists (fun event -> match event.T.Audit.event with - | T.Audit.Margin_limited _ -> true + | T.Audit.Fill_clipped _ -> true | _ -> false) events) -let margin_call_forces_deterministic_liquidation () = - let config = engine_config () in - let state = - Margin_runner.create ~run_id:(run_id "margin-call") ~scenario_sha256 ~config - ~initial_cash:[ ("USD", money "1000") ] - ~strategy_state:Margin_observing_strategy.initial - |> ok - in - let state, _ = Margin_runner.process_slice state (market_slice 1L) |> ok in - let stressed_bar = - bar ~open_price:"100" ~high_price:"100" ~low_price:"40" ~close_price:"40" 2L - in - let state, call_events = - Margin_runner.process_slice state (market_slice ~bars:[ stressed_bar ] 2L) - |> ok - in - Alcotest.(check bool) - "margin call emitted" true - (List.exists - (fun event -> - String.equal (T.Audit.event_name event.T.Audit.event) "margin_call") - call_events); - let liquidation = - T.Oms.active_orders (Margin_runner.oms state) - |> List.find (fun order -> - order.T.Order.request.origin = T.Order.Margin_liquidation) - in - Alcotest.(check string) - "forced sell" "sell" - (T.Order.side_to_string liquidation.request.side); - Alcotest.(check bool) - "strategy observes liquidation order updates" true - (Margin_runner.strategy_state state).saw_liquidation_update; - let liquidation_bar = - bar ~open_price:"40" ~high_price:"40" ~low_price:"40" ~close_price:"40" 3L - in - let state, restored_events = - Margin_runner.process_slice state - (market_slice ~bars:[ liquidation_bar ] 3L) - |> ok - in - Alcotest.check quantity_testable "position flattened" T.Scalar.Quantity.zero - (T.Account.position_quantity - (Margin_runner.account state) - (instrument_id "test-equity")); - Alcotest.(check bool) - "margin restored emitted" true - (List.exists - (fun event -> - String.equal (T.Audit.event_name event.T.Audit.event) "margin_restored") - restored_events) - -let short_borrow_accrues_before_matching () = - let target = - T.Strategy.Target_quantities - [ - T.Strategy. - { - instrument_id = instrument_id "test-equity"; - quantity = quantity "-10"; - }; - ] - in - let strategy_state = T.Scripted_strategy.create [ (1L, [ target ]) ] |> ok in - let config = engine_config ~risk:(risk ~short_borrow_bps:3650 ()) () in - let state = - Runner.create ~run_id:(run_id "short-borrow") ~scenario_sha256 ~config - ~initial_cash:[ ("USD", money "1000") ] - ~strategy_state - |> ok - in - let state, _ = Runner.process_slice state (market_slice 1L) |> ok in - let state, _ = Runner.process_slice state (market_slice 2L) |> ok in - let state, events = Runner.process_slice state (market_slice 3L) |> ok in - let borrow = - List.find_map - (fun event -> - match event.T.Audit.event with - | T.Audit.Borrow_fee_applied { fee; _ } -> Some fee - | _ -> None) - events - |> Option.get - in - Alcotest.(check bool) - "positive borrow fee" true - (T.Scalar.Money.compare borrow (money "0") > 0); - let position = - T.Account.position (Runner.account state) (instrument_id "test-equity") - in - Alcotest.check money_testable "borrow fee attributed" borrow - position.borrow_fees - let risk_allows_reducing_an_out_of_limit_position () = let configured_risk = risk ~max_long:"10" () in let account = test_account ~initial_cash:[ ("USD", money "10000") ] () in @@ -479,6 +378,69 @@ let risk_allows_reducing_an_out_of_limit_position () = (Result.is_error (risk_check configured_risk ~account ~oms:T.Oms.empty increase)) +let fill_clipping_reason_taxonomy_is_stable () = + let cases = + [ + ( T.Risk.Maximum_order_quantity (quantity "10"), + "max_order_quantity", + "quantity", + `String "10" ); + ( T.Risk.Maximum_long_position (quantity "20"), + "max_long_position", + "quantity", + `String "20" ); + ( T.Risk.Maximum_short_position (quantity "30"), + "max_short_position", + "quantity", + `String "30" ); + ( T.Risk.Maximum_gross_exposure (money "1000"), + "max_gross_exposure", + "money", + `String "1000" ); + ( T.Risk.Maximum_leverage (T.Scalar.Ratio.of_decimal_string "2" |> ok), + "max_leverage", + "ratio", + `String "2" ); + (T.Risk.Initial_margin 5000, "initial_margin", "basis_points", `Int 5000); + ] + in + List.iteri + (fun index (limit, expected_policy, expected_unit, expected_value) -> + let audit = + T.Audit.create ~contract_version:T.Contract.version + ~engine_sequence:(Int64.of_int (index + 1)) + ~causation_ids:[] ~run_id:(run_id "clip-taxonomy") + ~recorded_at:(timestamp "2026-01-02T21:00:02Z") + (T.Audit.Fill_clipped + { + order_id = order_id "clip-order"; + instrument_id = instrument_id "test-equity"; + proposed_quantity = quantity "10"; + permitted_quantity = quantity "5"; + price = price "100"; + limit; + }) + in + let open Yojson.Safe.Util in + let reason = + T.Codec.audit_to_yojson audit |> member "payload" |> member "reason" + in + Alcotest.(check string) + "reason version" "1" + (reason |> member "version" |> to_string); + Alcotest.(check string) + "limiting policy" expected_policy + (reason |> member "policy" |> to_string); + let threshold = reason |> member "threshold" in + Alcotest.(check string) + "threshold unit" expected_unit + (threshold |> member "unit" |> to_string); + Alcotest.(check string) + "threshold value" + (Yojson.Safe.to_string expected_value) + (threshold |> member "value" |> Yojson.Safe.to_string)) + cases + let engine_requires_complete_currency_ledgers () = let instruments = [ instrument (); euro_instrument () ] in let config = engine_config ~risk:(risk ~instruments ()) () in @@ -487,7 +449,8 @@ let engine_requires_complete_currency_ledgers () = "missing EUR ledger rejected" true (Result.is_error (Runner.create ~run_id:(run_id "missing-ledger") ~scenario_sha256 ~config - ~initial_cash:[ ("USD", money "1000") ] + ~initial_portfolio: + (initial_portfolio ~cash:[ ("USD", money "1000") ] ()) ~strategy_state)) let tests = @@ -504,12 +467,10 @@ let tests = split_caps_partially_filled_limit_remainder; Alcotest.test_case "reverse split restores order below maximum" `Quick reverse_split_restores_order_below_maximum; - Alcotest.test_case "margin call forces liquidation" `Quick - margin_call_forces_deterministic_liquidation; - Alcotest.test_case "short borrow accrues" `Quick - short_borrow_accrues_before_matching; Alcotest.test_case "risk allows reduction above position cap" `Quick risk_allows_reducing_an_out_of_limit_position; + Alcotest.test_case "fill clipping reason taxonomy is stable" `Quick + fill_clipping_reason_taxonomy_is_stable; Alcotest.test_case "engine requires complete currency ledgers" `Quick engine_requires_complete_currency_ledgers; ] diff --git a/test/test_contract_conformance.ml b/test/test_contract_conformance.ml new file mode 100644 index 0000000..65e75dd --- /dev/null +++ b/test/test_contract_conformance.ml @@ -0,0 +1,210 @@ +open Test_support +module T = Trading_engine + +type path_component = Field of string | Index of int + +let field name = function + | `Assoc fields -> List.assoc name fields + | _ -> Alcotest.failf "%s must be a JSON object field" name + +let string_field name json = + match field name json with + | `String value -> value + | _ -> Alcotest.failf "%s must be a JSON string" name + +let optional_field name = function + | `Assoc fields -> List.assoc_opt name fields + | _ -> Alcotest.failf "%s must be read from a JSON object" name + +let list_field name json = + match field name json with + | `List values -> values + | _ -> Alcotest.failf "%s must be a JSON array" name + +let path_of_yojson = function + | `List components -> + List.map + (function + | `String name -> Field name + | `Int index -> Index index + | _ -> Alcotest.fail "mutation paths contain only fields and indexes") + components + | _ -> Alcotest.fail "mutation path must be a JSON array" + +let rec find_path path json = + match (path, json) with + | [], value -> value + | Field name :: remaining, `Assoc fields -> + find_path remaining (List.assoc name fields) + | Index index :: remaining, `List values -> + find_path remaining (List.nth values index) + | _ -> Alcotest.fail "mutation path does not select a value" + +let rec set_path path replacement json = + match (path, json) with + | [], _ -> replacement + | Field name :: remaining, `Assoc fields -> + let found = ref false in + let fields = + List.map + (fun (candidate, value) -> + if String.equal candidate name then ( + found := true; + (candidate, set_path remaining replacement value)) + else (candidate, value)) + fields + in + let fields = + if !found then fields + else + match remaining with + | [] -> fields @ [ (name, replacement) ] + | _ -> Alcotest.fail "mutation cannot add a nested missing field" + in + `Assoc fields + | Index index :: remaining, `List values -> + `List + (List.mapi + (fun candidate value -> + if candidate = index then set_path remaining replacement value + else value) + values) + | _ -> Alcotest.fail "mutation path cannot be replaced" + +let rec remove_path path json = + match (path, json) with + | [ Field name ], `Assoc fields -> + `Assoc + (List.filter + (fun (candidate, _) -> not (String.equal name candidate)) + fields) + | Field name :: remaining, `Assoc fields -> + `Assoc + (List.map + (fun (candidate, value) -> + if String.equal candidate name then + (candidate, remove_path remaining value) + else (candidate, value)) + fields) + | Index index :: remaining, `List values -> + `List + (List.mapi + (fun candidate value -> + if candidate = index then remove_path remaining value else value) + values) + | _ -> Alcotest.fail "mutation path cannot be removed" + +let apply_mutation document mutation = + let operation = string_field "op" mutation in + let path = field "path" mutation |> path_of_yojson in + match operation with + | "remove" -> remove_path path document + | "add" | "replace" -> set_path path (field "value" mutation) document + | "append_copy" -> ( + let index = + match field "index" mutation with + | `Int value -> value + | _ -> Alcotest.fail "append_copy index must be an integer" + in + match find_path path document with + | `List values -> + set_path path (`List (values @ [ List.nth values index ])) document + | _ -> Alcotest.fail "append_copy path must select an array") + | value -> Alcotest.failf "unsupported mutation operation %s" value + +let apply_mutations case document = + List.fold_left apply_mutation document (list_field "mutations" case) + +let contract_path relative = Filename.concat "../contracts" relative +let read_json relative = contract_path relative |> Yojson.Safe.from_file + +let read_jsonl relative = + In_channel.with_open_bin (contract_path relative) In_channel.input_lines + |> List.filter (fun line -> not (String.equal line "")) + |> List.map Yojson.Safe.from_string + +let selected_record case records = + match optional_field "record" case with + | Some (`Int line_number) -> List.nth records (line_number - 1) + | _ -> Alcotest.fail "differential case must select a source record" + +let extracted case document = + match optional_field "extract" case with + | Some path -> find_path (path_of_yojson path) document + | None -> document + +let with_stream records function_ = + let path = Filename.temp_file "trading-engine-conformance" ".jsonl" in + Fun.protect + ~finally:(fun () -> if Sys.file_exists path then Sys.remove path) + (fun () -> + Out_channel.with_open_bin path (fun channel -> + List.iter + (fun record -> + Yojson.Safe.to_channel channel record; + output_char channel '\n') + records); + function_ path) + +let parse_stream path = + In_channel.with_open_bin path (fun channel -> + T.Scenario_stream.fold_channel + ~max_record_bytes:T.Resource_limits.scenario_record_bytes channel + ~init:(fun _ -> Ok ()) + ~step:(fun () _ -> Ok ()) + ~finish:(fun () ~slice_count:_ -> Ok ())) + +let runtime_result case = + let source = string_field "source" case in + match string_field "kind" case with + | "scenario" -> + read_json source |> apply_mutations case |> T.Scenario.of_yojson + |> Result.map (fun _ -> ()) + | "scenario_stream" -> + let records = read_jsonl source in + let records = + match optional_field "record" case with + | None -> records + | Some (`Int line_number) -> + List.mapi + (fun index record -> + if index = line_number - 1 then apply_mutations case record + else record) + records + | _ -> Alcotest.fail "stream record must be an integer" + in + with_stream records parse_stream + | "strategy_response" -> + let response = + read_jsonl source |> selected_record case |> extracted case + |> apply_mutations case + in + let expected_sequence = + string_field "expected_sequence" case |> Int64.of_string + in + T.Strategy_protocol.response_of_yojson ~expected_sequence response + |> Result.map (fun _ -> ()) + | kind -> Alcotest.failf "unsupported differential runtime kind %s" kind + +let check_case case = + let name = string_field "name" case in + let expected = string_field "runtime_expectation" case in + let accepted = Result.is_ok (runtime_result case) in + Alcotest.(check bool) + (name ^ " runtime expectation") + (String.equal expected "accept") + accepted + +let cases () = + match read_json "conformance/cases.json" with + | `Assoc fields -> ( + match List.assoc "cases" fields with + | `List values -> values + | _ -> Alcotest.fail "differential corpus cases must be an array") + | _ -> Alcotest.fail "differential corpus must be an object" + +let tests = + cases () + |> List.map (fun case -> + Alcotest.test_case (string_field "name" case) `Quick (fun () -> + check_case case)) diff --git a/test/test_corporate_lifecycle.ml b/test/test_corporate_lifecycle.ml new file mode 100644 index 0000000..ccffbba --- /dev/null +++ b/test/test_corporate_lifecycle.ml @@ -0,0 +1,508 @@ +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 ~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 ~market_events:[] + ~order_book_events:[] + |> ok + +let lifecycle_runner schedule run = + let config = engine_config ~contract_version:"1" () in + let strategy_state = T.Scripted_strategy.create schedule |> ok in + Runner.create ~run_id:(run_id run) ~scenario_sha256 ~config + ~initial_portfolio:(initial_portfolio ~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_development_environment.py b/test/test_development_environment.py new file mode 100644 index 0000000..4534a41 --- /dev/null +++ b/test/test_development_environment.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import importlib.util +import pathlib +import tempfile +import unittest + + +REPOSITORY_ROOT = pathlib.Path(__file__).resolve().parent.parent +MODULE_PATH = REPOSITORY_ROOT / "scripts" / "check-schema-environment.py" +SPEC = importlib.util.spec_from_file_location("check_schema_environment", MODULE_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"cannot load {MODULE_PATH}") +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class SchemaEnvironmentTest(unittest.TestCase): + def test_reads_exact_pins_and_normalizes_names(self) -> None: + with tempfile.TemporaryDirectory() as directory: + lock_path = pathlib.Path(directory) / "schema.lock" + lock_path.write_text( + "# generated\nTyping_Extensions==4.16.0\njsonschema==4.26.0\n", + encoding="utf-8", + ) + + self.assertEqual( + MODULE.locked_versions(lock_path), + {"typing-extensions": "4.16.0", "jsonschema": "4.26.0"}, + ) + + def test_rejects_a_lock_without_exact_pins(self) -> None: + with tempfile.TemporaryDirectory() as directory: + lock_path = pathlib.Path(directory) / "schema.lock" + lock_path.write_text("jsonschema>=4\n", encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "no pinned dependencies"): + MODULE.locked_versions(lock_path) + + def test_reports_missing_mismatched_and_unexpected_packages(self) -> None: + differences = MODULE.dependency_differences( + {"attrs": "26.1.0", "jsonschema": "4.26.0"}, + {"attrs": "25.0.0", "extra": "1.0.0"}, + ) + + self.assertEqual( + differences, + [ + "attrs: expected 26.1.0, found 25.0.0", + "jsonschema: expected 4.26.0, found missing", + "extra: installed but not locked", + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_diagnostic.ml b/test/test_diagnostic.ml new file mode 100644 index 0000000..89b337c --- /dev/null +++ b/test/test_diagnostic.ml @@ -0,0 +1,163 @@ +module T = Trading_engine + +let field name = function + | `Assoc fields -> List.assoc name fields + | _ -> Alcotest.fail "expected JSON object" + +let renders_stable_machine_context () = + let diagnostic = + T.Diagnostic.make ~code:T.Diagnostic.Scenario_stream_invalid + ~phase:T.Diagnostic.Validation ~json_path:"$.payload.market_slice" ~line:7 + ~sequence:9L ~event_id:"event-9" ~order_id:"order-2" + ~causation_ids:[ "event-7"; "event-8" ] "invalid market slice" + in + let json = T.Diagnostic.to_yojson diagnostic in + Alcotest.(check string) + "diagnostic version" "1" + (match field "diagnostic_version" json with + | `String value -> value + | _ -> Alcotest.fail "expected version"); + Alcotest.(check string) + "stable code" "scenario_stream.invalid" + (match field "code" json with + | `String value -> value + | _ -> Alcotest.fail "expected code"); + let context = field "context" json in + Alcotest.(check string) + "JSON path" "$.payload.market_slice" + (match field "json_path" context with + | `String value -> value + | _ -> Alcotest.fail "expected JSON path"); + Alcotest.(check int) + "line" 7 + (match field "line" context with + | `Int value -> value + | _ -> Alcotest.fail "expected line"); + Alcotest.(check string) + "sequence" "9" + (match field "sequence" context with + | `String value -> value + | _ -> Alcotest.fail "expected sequence") + +let preserves_sanitized_exception () = + let diagnostic = + T.Diagnostic.of_exception ~code:T.Diagnostic.Artifact_io + ~phase:T.Diagnostic.Artifact ~message:"could not publish artifact" + (Unix.Unix_error (Unix.EACCES, "link", "/tmp/output")) + in + let cause = T.Diagnostic.to_yojson diagnostic |> field "cause" in + Alcotest.(check string) + "cause kind" "unix_error" + (match field "kind" cause with + | `String value -> value + | _ -> Alcotest.fail "expected cause kind"); + Alcotest.(check string) + "operation" "link" + (match field "operation" cause with + | `String value -> value + | _ -> Alcotest.fail "expected operation"); + Alcotest.(check string) + "target" "/tmp/output" + (match field "target" cause with + | `String value -> value + | _ -> Alcotest.fail "expected target") + +let capabilities_publish_versioned_resource_limits () = + let limits = + T.Contract.capabilities_to_yojson () |> field "resource_limits" + in + Alcotest.(check string) + "resource contract version" T.Resource_limits.version + (match field "version" limits with + | `String value -> value + | _ -> Alcotest.fail "expected resource limit version"); + List.iter + (fun (name, expected) -> + Alcotest.(check int) + name expected + (match field name limits with + | `Int value -> value + | _ -> Alcotest.fail ("expected integer limit: " ^ name))) + [ + ("scenario_record_bytes", T.Resource_limits.scenario_record_bytes); + ("strategy_message_bytes", T.Resource_limits.strategy_message_bytes); + ("internal_events", T.Resource_limits.internal_events); + ("catalog_instruments", T.Resource_limits.catalog_instruments); + ("intents_per_batch", T.Resource_limits.intents_per_batch); + ("artifact_record_bytes", T.Resource_limits.artifact_record_bytes); + ]; + Alcotest.(check string) + "resource diagnostic code" "resource.limit" + (T.Diagnostic.code_to_string T.Diagnostic.Resource_limit) + +let capabilities_describe_execution_contracts () = + let models = + match + T.Contract.capabilities_to_yojson () |> field "execution_model_contracts" + with + | `List models -> models + | _ -> Alcotest.fail "expected execution-model capabilities" + in + let names = + List.map + (fun model -> + match field "name" model with + | `String value -> value + | _ -> Alcotest.fail "expected execution-model name") + models + in + Alcotest.(check (list string)) + "stable model catalog" + [ + "completed_bar_v1"; + "completed_bar_next_open_v1"; + "completed_bar_adverse_touch_v1"; + "quote_trade_v1"; + "order_book_v1"; + ] + names; + let model = List.hd models in + Alcotest.(check string) + "stable model name" "completed_bar_v1" + (match field "name" model with + | `String value -> value + | _ -> Alcotest.fail "expected execution-model name"); + let strings name = + match field name model with + | `List values -> + List.map + (function + | `String value -> value + | _ -> Alcotest.fail (name ^ " must contain strings")) + values + | _ -> Alcotest.fail (name ^ " must be an array") + in + Alcotest.(check (list string)) + "configuration versions" [ "1" ] + (strings "configuration_versions"); + Alcotest.(check (list string)) + "scenario contracts" [ "1" ] + (strings "scenario_contract_versions"); + Alcotest.(check (list string)) + "required fields" + [ "version"; "participation_bps"; "fee_schedules" ] + (strings "required_fields"); + Alcotest.(check (list string)) + "order types" + [ "market"; "limit"; "stop"; "stop_limit" ] + (strings "supported_order_types"); + Alcotest.(check (list string)) + "market data" [ "completed_ohlcv_bars" ] + (strings "data_requirements") + +let tests = + [ + Alcotest.test_case "renders stable machine context" `Quick + renders_stable_machine_context; + Alcotest.test_case "preserves sanitized exception" `Quick + preserves_sanitized_exception; + Alcotest.test_case "versioned resource capabilities" `Quick + capabilities_publish_versioned_resource_limits; + Alcotest.test_case "execution-model capabilities" `Quick + capabilities_describe_execution_contracts; + ] diff --git a/test/test_documentation.py b/test/test_documentation.py new file mode 100644 index 0000000..84cc9e4 --- /dev/null +++ b/test/test_documentation.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import importlib.util +import pathlib +import tempfile +import unittest + + +REPOSITORY_ROOT = pathlib.Path(__file__).resolve().parent.parent +MODULE_PATH = REPOSITORY_ROOT / "scripts" / "check-documentation.py" +SPEC = importlib.util.spec_from_file_location("check_documentation", MODULE_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"cannot load {MODULE_PATH}") +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class DocumentationCheckTest(unittest.TestCase): + def test_reports_missing_and_insecure_links(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + page = root / "page.md" + page.write_text( + "[missing](missing.md) [insecure](http://example.com) " + "[secure](https://example.com)\n", + encoding="utf-8", + ) + + self.assertEqual( + MODULE.markdown_link_failures(page, root), + [ + f"{page}: missing link target missing.md", + f"{page}: insecure external link http://example.com", + ], + ) + + def test_resolves_generated_directory_links(self) -> None: + with tempfile.TemporaryDirectory() as directory: + site = pathlib.Path(directory) + page = site / "guide" / "index.html" + target = site / "api" / "index.html" + page.parent.mkdir() + target.parent.mkdir() + page.write_text('API', encoding="utf-8") + target.write_text("API", encoding="utf-8") + + self.assertEqual(MODULE.generated_link_failures(site), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_domain.ml b/test/test_domain.ml index f47b310..5597946 100644 --- a/test/test_domain.ml +++ b/test/test_domain.ml @@ -12,6 +12,29 @@ let identifier_validation () = "round trip" "order-1" (T.Id.Order.of_string_exn "order-1" |> T.Id.Order.to_string) +let instrument_validation () = + let create ?(symbol = "TEST") ?(quote_currency = "USD") + ?(lot_size = quantity "1") () = + T.Instrument.create + ~id:(instrument_id "test-equity") + ~symbol ~quote_currency ~tick_size:(price "0.01") ~lot_size + in + Alcotest.(check bool) + "empty symbol rejected" true + (Result.is_error (create ~symbol:"" ())); + Alcotest.(check bool) + "symbol whitespace rejected" true + (Result.is_error (create ~symbol:"BAD SYMBOL" ())); + Alcotest.(check bool) + "quote currency whitespace rejected" true + (Result.is_error (create ~quote_currency:" BAD" ())); + Alcotest.(check bool) + "zero lot size rejected" true + (Result.is_error (create ~lot_size:T.Scalar.Quantity.zero ())); + Alcotest.(check string) + "rendered instrument" "TEST (test-equity)" + (Format.asprintf "%a" T.Instrument.pp (create () |> ok)) + let scalar_decimal_round_trip () = let values = [ "0"; "1"; "1.25"; "-0.5"; "999999.000001" ] in List.iter @@ -95,11 +118,288 @@ let market_slice_validation () = ~received_at ~bars:[ bar 1L ] ~fx_rates:[ fx_mark () ] - ~corporate_actions:[] + ~corporate_actions:[] ~borrow_observations:[] ~cash_rate_observations:[] + ~settlement_failures:[] ~lifecycle_events:[] ~market_events:[] + ~order_book_events:[] in Alcotest.(check bool) "premature availability rejected" true (Result.is_error result) +let market_event_validation () = + let instrument_id = instrument_id "event-validation" in + let event_at = timestamp "2026-01-03T14:30:00Z" in + let available_at = timestamp "2026-01-03T14:30:01Z" in + let received_at = timestamp "2026-01-03T14:30:02Z" in + let quote ?(ingest_sequence = 1L) ?(bid_price = "99") + ?(bid_quantity = quantity "1") ?(ask_price = "101") () = + T.Market_event.quote ~instrument_id ~event_at ~available_at ~received_at + ~ingest_sequence ~bid_price:(price bid_price) ~bid_quantity + ~ask_price:(price ask_price) ~ask_quantity:(quantity "1") + in + Alcotest.(check bool) + "nonpositive ingest rejected" true + (Result.is_error (quote ~ingest_sequence:0L ())); + Alcotest.(check bool) + "crossed quote rejected" true + (Result.is_error (quote ~bid_price:"101" ~ask_price:"100" ())); + Alcotest.(check bool) + "zero displayed quantity rejected" true + (Result.is_error (quote ~bid_quantity:T.Scalar.Quantity.zero ())); + Alcotest.(check bool) + "zero ask quantity rejected" true + (Result.is_error + (T.Market_event.quote ~instrument_id ~event_at ~available_at ~received_at + ~ingest_sequence:1L ~bid_price:(price "99") + ~bid_quantity:(quantity "1") ~ask_price:(price "101") + ~ask_quantity:T.Scalar.Quantity.zero)); + Alcotest.(check bool) + "receipt before availability rejected" true + (Result.is_error + (T.Market_event.trade ~instrument_id ~event_at ~available_at + ~received_at:event_at ~ingest_sequence:1L ~price:(price "100") + ~quantity:(quantity "1") ~aggressor_side:T.Market_event.Sell)); + Alcotest.(check bool) + "zero trade quantity rejected" true + (Result.is_error + (T.Market_event.trade ~instrument_id ~event_at ~available_at ~received_at + ~ingest_sequence:1L ~price:(price "100") + ~quantity:T.Scalar.Quantity.zero + ~aggressor_side:T.Market_event.Unknown)); + Alcotest.(check bool) + "availability before event rejected" true + (Result.is_error + (T.Market_event.trade ~instrument_id ~event_at + ~available_at:(timestamp "2026-01-03T14:29:59Z") + ~received_at ~ingest_sequence:1L ~price:(price "100") + ~quantity:(quantity "1") ~aggressor_side:T.Market_event.Buy)); + let first = quote ~ingest_sequence:2L () |> ok in + let second = quote ~ingest_sequence:1L () |> ok in + let later_receipt = + T.Market_event.quote ~instrument_id ~event_at ~available_at + ~received_at:(timestamp "2026-01-03T14:30:03Z") + ~ingest_sequence:3L ~bid_price:(price "99") ~bid_quantity:(quantity "1") + ~ask_price:(price "101") ~ask_quantity:(quantity "1") + |> ok + in + Alcotest.(check bool) + "receipt breaks replay-order tie" true + (T.Market_event.compare_replay_order first later_receipt < 0); + List.iter + (fun (wire, side) -> + Alcotest.(check string) + (wire ^ " side round trip") + wire + (T.Market_event.aggressor_side_of_string wire + |> ok |> T.Market_event.aggressor_side_to_string); + Alcotest.(check string) + (wire ^ " constructor rendering") + wire + (T.Market_event.aggressor_side_to_string side)) + [ + ("buy", T.Market_event.Buy); + ("sell", T.Market_event.Sell); + ("unknown", T.Market_event.Unknown); + ]; + Alcotest.(check bool) + "unknown aggressor spelling rejected" true + (Result.is_error (T.Market_event.aggressor_side_of_string "ambiguous")); + let base = market_slice 2L in + Alcotest.(check string) + "slice rendering includes market-event count" + "slice[2] bars=1 events=0 book_events=0 fx=1 actions=0 lifecycle=0 \ + borrow=0 cash_rates=0 failures=0" + (Format.asprintf "%a" T.Market_slice.pp base); + Alcotest.(check bool) + "nonmonotonic ingest rejected" true + (Result.is_error + (T.Market_slice.create ~slice_sequence:base.slice_sequence + ~start_at:base.start_at ~end_at:base.end_at + ~available_at:base.available_at ~received_at:base.received_at + ~bars:base.bars ~fx_rates:base.fx_rates + ~corporate_actions:base.corporate_actions + ~borrow_observations:base.borrow_observations + ~cash_rate_observations:base.cash_rate_observations + ~settlement_failures:base.settlement_failures + ~lifecycle_events:base.lifecycle_events + ~market_events:[ first; second ] ~order_book_events:[])) + +let order_book_event_validation () = + let instrument_id = instrument_id "book-validation" in + let event_at = timestamp "2026-01-03T14:30:00Z" in + let available_at = timestamp "2026-01-03T14:30:01Z" in + let received_at = timestamp "2026-01-03T14:30:02Z" in + let level price_value quantity_value = + T.Order_book_event.level ~price:(price price_value) + ~quantity:(quantity quantity_value) + |> ok + in + let snapshot bids asks = + T.Order_book_event.snapshot ~instrument_id ~event_at ~available_at + ~received_at ~ingest_sequence:1L ~book_sequence:1L ~bids ~asks + in + Alcotest.(check bool) + "locked snapshot accepted" true + (Result.is_ok (snapshot [ level "100" "2" ] [ level "100" "3" ])); + Alcotest.(check bool) + "crossed snapshot rejected" true + (Result.is_error (snapshot [ level "101" "2" ] [ level "100" "3" ])); + Alcotest.(check bool) + "unordered duplicate depth rejected" true + (Result.is_error + (snapshot [ level "100" "2"; level "100" "3" ] [ level "101" "2" ])); + Alcotest.(check bool) + "empty side rejected" true + (Result.is_error (snapshot [] [ level "101" "2" ])); + Alcotest.(check bool) + "zero level rejected" true + (Result.is_error + (T.Order_book_event.level ~price:(price "100") + ~quantity:T.Scalar.Quantity.zero)); + Alcotest.(check bool) + "zero ingest sequence rejected" true + (Result.is_error + (T.Order_book_event.snapshot ~instrument_id ~event_at ~available_at + ~received_at ~ingest_sequence:0L ~book_sequence:1L + ~bids:[ level "99" "1" ] + ~asks:[ level "101" "1" ])); + Alcotest.(check bool) + "zero book sequence rejected" true + (Result.is_error + (T.Order_book_event.snapshot ~instrument_id ~event_at ~available_at + ~received_at ~ingest_sequence:1L ~book_sequence:0L + ~bids:[ level "99" "1" ] + ~asks:[ level "101" "1" ])); + Alcotest.(check bool) + "availability before book event rejected" true + (Result.is_error + (T.Order_book_event.delete ~instrument_id ~event_at + ~available_at:(timestamp "2026-01-03T14:29:59Z") + ~received_at ~ingest_sequence:1L ~book_sequence:1L + ~side:T.Order_book_event.Bid ~price:(price "99"))); + Alcotest.(check bool) + "receipt before book availability rejected" true + (Result.is_error + (T.Order_book_event.delete ~instrument_id ~event_at ~available_at + ~received_at:event_at ~ingest_sequence:1L ~book_sequence:1L + ~side:T.Order_book_event.Bid ~price:(price "99"))); + Alcotest.(check bool) + "zero set rejected" true + (Result.is_error + (T.Order_book_event.set ~instrument_id ~event_at ~available_at + ~received_at ~ingest_sequence:1L ~book_sequence:1L + ~side:T.Order_book_event.Bid ~price:(price "99") + ~quantity:T.Scalar.Quantity.zero)); + Alcotest.(check bool) + "zero book trade rejected" true + (Result.is_error + (T.Order_book_event.trade ~instrument_id ~event_at ~available_at + ~received_at ~ingest_sequence:1L ~book_sequence:1L ~price:(price "99") + ~quantity:T.Scalar.Quantity.zero ~aggressor_side:T.Market_event.Sell)); + Alcotest.(check bool) + "unknown book side rejected" true + (Result.is_error (T.Order_book_event.side_of_string "offer")); + let earlier = snapshot [ level "99" "1" ] [ level "101" "1" ] |> ok in + let later = + T.Order_book_event.delete ~instrument_id ~event_at ~available_at + ~received_at:(timestamp "2026-01-03T14:30:03Z") + ~ingest_sequence:2L ~book_sequence:2L ~side:T.Order_book_event.Bid + ~price:(price "99") + |> ok + in + Alcotest.(check bool) + "book receipt orders replay" true + (T.Order_book_event.compare_replay_order earlier later < 0); + let same_time_later_sequence = + T.Order_book_event.delete ~instrument_id ~event_at ~available_at + ~received_at ~ingest_sequence:2L ~book_sequence:2L + ~side:T.Order_book_event.Bid ~price:(price "99") + |> ok + in + Alcotest.(check bool) + "book ingest sequence orders final replay tie" true + (T.Order_book_event.compare_replay_order earlier same_time_later_sequence + < 0); + List.iter + (fun (wire, side) -> + Alcotest.(check string) + (wire ^ " book side round trip") + wire + (T.Order_book_event.side_of_string wire + |> ok |> T.Order_book_event.side_to_string); + Alcotest.(check string) + (wire ^ " book side rendering") + wire + (T.Order_book_event.side_to_string side)) + [ ("bid", T.Order_book_event.Bid); ("ask", T.Order_book_event.Ask) ] + +let bar_validation_boundaries () = + let instrument_id = instrument_id "bar-validation" in + let create ?(open_price = "100") ?(high_price = "110") ?(low_price = "90") + ?(close_price = "105") ?(volume = Some "10") () = + T.Bar.create ~instrument_id ~open_price:(price open_price) + ~high_price:(price high_price) ~low_price:(price low_price) + ~close_price:(price close_price) + ~volume:(Option.map quantity volume) + in + let rejects label expected result = + Alcotest.(check string) label expected (error result) + in + rejects "inverted range" "bar low must not exceed its high" + (create ~high_price:"90" ~low_price:"100" ()); + rejects "open below range" "bar open must lie inside its low-high range" + (create ~open_price:"89" ()); + rejects "open above range" "bar open must lie inside its low-high range" + (create ~open_price:"111" ()); + rejects "close below range" "bar close must lie inside its low-high range" + (create ~close_price:"89" ()); + rejects "close above range" "bar close must lie inside its low-high range" + (create ~close_price:"111" ()); + rejects "negative volume" "bar volume must be nonnegative" + (create ~volume:(Some "-1") ()); + let valid = create ~volume:None () |> ok in + Alcotest.(check string) + "rendered close" "bar[bar-validation] close=105" + (Format.asprintf "%a" T.Bar.pp valid) + +let corporate_action_validation_boundaries () = + let id value = T.Id.Corporate_action.of_string_exn value in + let instrument_id = instrument_id "action-validation" in + let split ?(numerator = 2L) ?(denominator = 1L) action_id = + T.Corporate_action.split ~id:(id action_id) ~instrument_id ~numerator + ~denominator + in + Alcotest.(check string) + "zero numerator" "split numerator and denominator must be positive" + (error (split ~numerator:0L "zero-numerator")); + Alcotest.(check string) + "zero denominator" "split numerator and denominator must be positive" + (error (split ~denominator:0L "zero-denominator")); + Alcotest.(check string) + "unchanged units" "split ratio must change the instrument units" + (error (split ~numerator:1L "identity-split")); + let split_action = split "split" |> ok in + let invalid_dividend = + T.Corporate_action.cash_dividend ~id:(id "invalid-dividend") ~instrument_id + ~amount_per_unit:(money "0") + in + Alcotest.(check string) + "zero dividend" "cash dividend amount per unit must be positive" + (error invalid_dividend); + let dividend = + T.Corporate_action.cash_dividend ~id:(id "dividend") ~instrument_id + ~amount_per_unit:(money "0.25") + |> ok + in + Alcotest.(check bool) + "actions compare by ID" true + (T.Corporate_action.compare dividend split_action < 0); + Alcotest.(check string) + "split rendering" "split split 2:1 action-validation" + (Format.asprintf "%a" T.Corporate_action.pp split_action); + Alcotest.(check string) + "dividend rendering" "dividend dividend 0.25 action-validation" + (Format.asprintf "%a" T.Corporate_action.pp dividend) + let sha256_vectors () = Alcotest.(check string) "empty" "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" @@ -202,27 +502,69 @@ let risk_limits_cover_lots () = Alcotest.(check bool) "order limit smaller than lot rejected" true (Result.is_error - (T.Risk.create ~base_currency:"USD" ~instruments:[ configured ] + (T.Risk.create_instrument_policy ~instrument:configured ~max_order_quantity:(quantity "5") ~max_long_position:(quantity "100") - ~max_short_position:(quantity "100") - ~max_gross_exposure:(money "1000000") - ~max_leverage:(T.Scalar.Ratio.of_decimal_string "2" |> ok) + ~max_short_position:(quantity "100") ~max_notional_exposure:None ~initial_margin_bps:5000 ~maintenance_margin_bps:2500 - ~short_borrow_bps:100)); + ~shorting_allowed:true)); Alcotest.(check bool) "position limit smaller than lot rejected" true (Result.is_error - (T.Risk.create ~base_currency:"USD" ~instruments:[ configured ] + (T.Risk.create_instrument_policy ~instrument:configured ~max_order_quantity:(quantity "100") ~max_long_position:(quantity "5") - ~max_short_position:(quantity "100") - ~max_gross_exposure:(money "1000000") - ~max_leverage:(T.Scalar.Ratio.of_decimal_string "2" |> ok) + ~max_short_position:(quantity "100") ~max_notional_exposure:None ~initial_margin_bps:5000 ~maintenance_margin_bps:2500 - ~short_borrow_bps:100)) + ~shorting_allowed:true)) + +let typed_metric_validation () = + let numeric = T.Metric.numeric_of_string "-12.5" |> ok in + Alcotest.(check string) + "negative fractional numeric" "-0.5" + (T.Metric.numeric_of_string "-0.5" |> ok |> T.Metric.numeric_to_string); + let metric = + T.Metric.create ~name:"strategy.signal" ~value:(T.Metric.Numeric numeric) + ~unit_:"ratio" + ~dimensions:[ ("venue", "XNYS"); ("asset", "ACME") ] + ~aggregation:T.Metric.Mean () + |> ok + in + Alcotest.(check string) + "numeric round trip" "-12.5" + (match metric.value with + | T.Metric.Numeric value -> T.Metric.numeric_to_string value + | _ -> Alcotest.fail "expected numeric metric"); + Alcotest.(check (list string)) + "dimensions sort canonically" [ "asset"; "venue" ] + (List.map (fun dimension -> dimension.T.Metric.key) metric.dimensions); + List.iter + (fun invalid -> + Alcotest.(check bool) + (invalid ^ " rejected") true + (Result.is_error (T.Metric.numeric_of_string invalid))) + [ ""; "01"; "1.0"; "-0"; "+1"; "1e3" ]; + Alcotest.(check bool) + "duplicate dimensions rejected" true + (Result.is_error + (T.Metric.create ~name:"duplicate" ~value:(T.Metric.Boolean true) + ~dimensions:[ ("side", "buy"); ("side", "sell") ] + ())); + Alcotest.(check bool) + "nonnumeric aggregation rejected" true + (Result.is_error + (T.Metric.create ~name:"state" ~value:(T.Metric.String "risk-on") + ~aggregation:T.Metric.Mean ())); + Alcotest.(check bool) + "dimension count bounded" true + (Result.is_error + (T.Metric.create ~name:"bounded" ~value:(T.Metric.String "ok") + ~dimensions: + (List.init 17 (fun index -> (Printf.sprintf "key-%02d" index, "x"))) + ())) let tests = [ Alcotest.test_case "identifier validation" `Quick identifier_validation; + Alcotest.test_case "instrument validation" `Quick instrument_validation; Alcotest.test_case "fixed-point decimal round trip" `Quick scalar_decimal_round_trip; Alcotest.test_case "checked overflow" `Quick scalar_overflow_is_rejected; @@ -230,6 +572,13 @@ let tests = Alcotest.test_case "portfolio weight rounds toward zero" `Quick portfolio_weight_rounds_toward_zero; Alcotest.test_case "market slice validation" `Quick market_slice_validation; + Alcotest.test_case "market event validation" `Quick market_event_validation; + Alcotest.test_case "order-book event validation" `Quick + order_book_event_validation; + Alcotest.test_case "bar validation boundaries" `Quick + bar_validation_boundaries; + Alcotest.test_case "corporate action validation boundaries" `Quick + corporate_action_validation_boundaries; Alcotest.test_case "SHA-256 vectors" `Quick sha256_vectors; Alcotest.test_case "OMS partial and duplicate fills" `Quick oms_partial_fill_and_duplicate; @@ -241,4 +590,5 @@ let tests = Alcotest.test_case "risk accepts multiple currencies" `Quick risk_accepts_multiple_currencies; Alcotest.test_case "risk limits cover lots" `Quick risk_limits_cover_lots; + Alcotest.test_case "typed metric validation" `Quick typed_metric_validation; ] diff --git a/test/test_engine.ml b/test/test_engine.ml index 3e837aa..fd1bd50 100644 --- a/test/test_engine.ml +++ b/test/test_engine.ml @@ -1,11 +1,22 @@ let () = Alcotest.run "trading-engine" [ + ("diagnostic", Test_diagnostic.tests); ("domain", Test_domain.tests); ("accounting", Test_accounting.tests); ("execution", Test_execution.tests); + ("order-lifetimes", Test_order_lifetimes.tests); + ("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); ("strategy-protocol", Test_strategy_protocol.tests); + ("contract-conformance", Test_contract_conformance.tests); + ("boundary-failures", Test_boundary_failures.tests); + ("venue-calendar", Test_venue_calendar.tests); + ("risk-groups", Test_risk_groups.tests); ("scenario", Test_scenario.tests); ] diff --git a/test/test_execution.ml b/test/test_execution.ml index 9881a24..e088e97 100644 --- a/test/test_execution.ml +++ b/test/test_execution.ml @@ -11,6 +11,847 @@ let match_orders ?(configured = instrument ()) ?(engine = execution ()) ~oms T.Execution.match_slice engine ~instruments:[ configured ] ~oms market_slice |> ok +let conservative_execution ?(half_spread_bps = 0) ?(impact_coefficient_bps = 0) + ?(missing_volume_policy = T.Execution.Reject_missing_volume) () = + let component = + T.Fee_schedule.create_component ~name:"broker" ~currency:"USD" + ~basis:(T.Fee_schedule.Fixed (money "0.1")) + ~rounding:T.Fee_schedule.Up ~applicability:T.Fee_schedule.Any + |> ok + in + let schedule = + T.Fee_schedule.create ~schedule_id:"test-fees-v1" + ~instrument_id:(instrument_id "test-equity") + ~settlement_currency:"USD" ~minimum:None ~maximum:None + ~components:[ component ] + |> ok + in + T.Execution.create_conservative ~participation_bps:10_000 + ~fee_schedules:[ schedule ] ~half_spread_bps ~impact_coefficient_bps + ~missing_volume_policy + |> ok + +let conservative_step start ?(kind = T.Order.Market) ?(side = T.Order.Buy) + ?(slice = market_slice 2L) engine = + let oms, _ = oms_with_order (request ~kind ~side ()) in + let cursor = start engine ~instruments:[ instrument () ] ~oms slice |> ok in + T.Execution.next cursor ~oms |> ok + +let market_event_time second = + timestamp (Printf.sprintf "2026-01-03T14:30:%02dZ" second) + +let quote_event ?(sequence = 1L) ?(second = 1) ?(bid = "99") + ?(bid_quantity = "5") ?(ask = "101") ?(ask_quantity = "5") () = + let event_at = market_event_time second in + T.Market_event.quote + ~instrument_id:(instrument_id "test-equity") + ~event_at ~available_at:event_at ~received_at:event_at + ~ingest_sequence:sequence ~bid_price:(price bid) + ~bid_quantity:(quantity bid_quantity) ~ask_price:(price ask) + ~ask_quantity:(quantity ask_quantity) + |> ok + +let trade_event ?(sequence = 2L) ?(second = 2) ?(price_value = "100") + ?(quantity_value = "5") ?(aggressor_side = T.Market_event.Unknown) () = + let event_at = market_event_time second in + T.Market_event.trade + ~instrument_id:(instrument_id "test-equity") + ~event_at ~available_at:event_at ~received_at:event_at + ~ingest_sequence:sequence ~price:(price price_value) + ~quantity:(quantity quantity_value) ~aggressor_side + |> ok + +let quote_trade_slice events = + let base = market_slice 2L in + T.Market_slice.create ~slice_sequence:base.slice_sequence + ~start_at:base.start_at ~end_at:base.end_at ~available_at:base.available_at + ~received_at:base.received_at ~bars:base.bars ~fx_rates:base.fx_rates + ~corporate_actions:base.corporate_actions + ~borrow_observations:base.borrow_observations + ~cash_rate_observations:base.cash_rate_observations + ~settlement_failures:base.settlement_failures + ~lifecycle_events:base.lifecycle_events ~market_events:events + ~order_book_events:[] + |> ok + +let quote_trade_execution ?(participation_bps = 10_000) () = + let fees = conservative_execution () |> T.Execution.fee_schedules in + T.Execution.create ~participation_bps ~fee_schedules:fees |> ok + +let book_level price_value quantity_value = + T.Order_book_event.level ~price:(price price_value) + ~quantity:(quantity quantity_value) + |> ok + +let book_snapshot ?(sequence = 1L) ?(second = 1) + ?(bids = [ book_level "99" "10" ]) ?(asks = [ book_level "101" "10" ]) () = + let event_at = market_event_time second in + T.Order_book_event.snapshot + ~instrument_id:(instrument_id "test-equity") + ~event_at ~available_at:event_at ~received_at:event_at + ~ingest_sequence:sequence ~book_sequence:sequence ~bids ~asks + |> ok + +let book_set ?(sequence = 2L) ?(second = 2) ?(side = T.Order_book_event.Bid) + ?(price_value = "99") ?(quantity_value = "5") () = + let event_at = market_event_time second in + T.Order_book_event.set + ~instrument_id:(instrument_id "test-equity") + ~event_at ~available_at:event_at ~received_at:event_at + ~ingest_sequence:sequence ~book_sequence:sequence ~side + ~price:(price price_value) ~quantity:(quantity quantity_value) + |> ok + +let book_delete ?(sequence = 2L) ?(second = 2) ?(side = T.Order_book_event.Bid) + ?(price_value = "99") () = + let event_at = market_event_time second in + T.Order_book_event.delete + ~instrument_id:(instrument_id "test-equity") + ~event_at ~available_at:event_at ~received_at:event_at + ~ingest_sequence:sequence ~book_sequence:sequence ~side + ~price:(price price_value) + |> ok + +let book_trade ?(sequence = 2L) ?(second = 2) ?(price_value = "99") + ?(quantity_value = "5") ?(aggressor_side = T.Market_event.Sell) () = + let event_at = market_event_time second in + T.Order_book_event.trade + ~instrument_id:(instrument_id "test-equity") + ~event_at ~available_at:event_at ~received_at:event_at + ~ingest_sequence:sequence ~book_sequence:sequence ~price:(price price_value) + ~quantity:(quantity quantity_value) ~aggressor_side + |> ok + +let order_book_slice events = + let base = market_slice 2L in + T.Market_slice.create ~slice_sequence:base.slice_sequence + ~start_at:base.start_at ~end_at:base.end_at ~available_at:base.available_at + ~received_at:base.received_at ~bars:base.bars ~fx_rates:base.fx_rates + ~corporate_actions:base.corporate_actions + ~borrow_observations:base.borrow_observations + ~cash_rate_observations:base.cash_rate_observations + ~settlement_failures:base.settlement_failures + ~lifecycle_events:base.lifecycle_events ~market_events:[] + ~order_book_events:events + |> ok + +let order_book_execution ?(max_depth_levels = 10) () = + let fees = conservative_execution () |> T.Execution.fee_schedules in + T.Execution.create_order_book ~participation_bps:10_000 ~fee_schedules:fees + ~max_depth_levels + |> ok + +let liquidity_name = function + | T.Fee_schedule.Maker -> "maker" + | Taker -> "taker" + +let quote_trade_step ?(kind = T.Order.Market) ?(side = T.Order.Buy) events = + let oms, _ = oms_with_order (request ~kind ~side ()) in + let cursor = + T.Execution.start_slice_quote_trade (quote_trade_execution ()) + ~instruments:[ instrument () ] + ~oms (quote_trade_slice events) + |> ok + in + T.Execution.next cursor ~oms |> ok + +let quote_trade_consumes_displayed_liquidity () = + match quote_trade_step [ quote_event ~ask_quantity:"3" () ] with + | T.Execution.Proposed (proposal, _) -> + Alcotest.check price_testable "buy executes at displayed ask" + (price "101") proposal.price; + Alcotest.check quantity_testable "displayed size caps fill" (quantity "3") + proposal.quantity; + Alcotest.(check string) + "quote fill is taker" "taker" + (liquidity_name proposal.liquidity); + Alcotest.(check string) + "economic event time" "2026-01-03T14:30:01.000000Z" + (T.Codec.ptime_to_string proposal.executed_at) + | _ -> Alcotest.fail "marketable quote did not produce a fill" + +let quote_trade_passive_fills_require_aggressor_evidence () = + let limit = T.Order.Limit (price "100") in + let events = + [ + quote_event (); + trade_event ~price_value:"99" (); + trade_event ~sequence:3L ~second:3 ~price_value:"99" + ~aggressor_side:T.Market_event.Sell (); + ] + in + match quote_trade_step ~kind:limit events with + | T.Execution.Proposed (proposal, _) -> + Alcotest.check price_testable "passive fill uses observed trade" + (price "99") proposal.price; + Alcotest.(check string) + "trade fill is maker" "maker" + (liquidity_name proposal.liquidity); + Alcotest.(check string) + "unknown aggressor was skipped" "2026-01-03T14:30:03.000000Z" + (T.Codec.ptime_to_string proposal.executed_at) + | _ -> Alcotest.fail "qualified passive trade did not produce a fill" + +let quote_trade_sell_paths_use_bid_and_buy_aggressors () = + (match quote_trade_step ~side:T.Order.Sell [ quote_event ~bid:"99" () ] with + | T.Execution.Proposed (proposal, continue) -> ( + Alcotest.check price_testable "sell executes at displayed bid" + (price "99") proposal.price; + let cursor = continue proposal.quantity |> ok in + match T.Execution.next cursor ~oms:T.Oms.empty |> ok with + | T.Execution.Finished _ -> () + | _ -> Alcotest.fail "consumed quote should finish") + | _ -> Alcotest.fail "sell quote did not produce a fill"); + let passive = T.Order.Limit (price "100") in + match + quote_trade_step ~kind:passive ~side:T.Order.Sell + [ trade_event ~price_value:"101" ~aggressor_side:T.Market_event.Buy () ] + with + | T.Execution.Proposed (proposal, continue) -> + Alcotest.check price_testable "passive sell uses trade price" + (price "101") proposal.price; + Alcotest.(check string) + "passive sell is maker" "maker" + (liquidity_name proposal.liquidity); + ignore (continue proposal.quantity |> ok) + | _ -> Alcotest.fail "buy-aggressor trade did not fill passive sell" + +let quote_trade_limits_fok_and_continuations () = + let marketable = T.Order.Limit (price "102") in + (match + quote_trade_step ~kind:marketable [ quote_event ~ask_quantity:"3" () ] + with + | T.Execution.Proposed (proposal, continue) -> + Alcotest.check quantity_testable "marketable limit uses displayed size" + (quantity "3") proposal.quantity; + Alcotest.(check bool) + "over-consumption rejected" true + (Result.is_error (continue (quantity "4"))); + Alcotest.(check bool) + "negative application rejected" true + (Result.is_error (continue (quantity "-1"))) + | _ -> Alcotest.fail "marketable limit did not execute"); + let oms, _ = + oms_with_order (request ~kind:T.Order.Market ~time_in_force:T.Order.Fok ()) + in + let cursor = + T.Execution.start_slice_quote_trade (quote_trade_execution ()) + ~instruments:[ instrument () ] + ~oms + (quote_trade_slice [ quote_event ~ask_quantity:"3" () ]) + |> ok + in + match T.Execution.next cursor ~oms |> ok with + | T.Execution.Finished _ -> () + | _ -> Alcotest.fail "FOK order filled partial displayed liquidity" + +let quote_trade_stop_and_event_boundaries () = + let oms, order = + oms_with_order + (request ~kind:(T.Order.Stop (price "100")) ~time_in_force:T.Order.Gtc ()) + in + let cursor = + T.Execution.start_slice_quote_trade (quote_trade_execution ()) + ~instruments:[ instrument () ] + ~oms + (quote_trade_slice [ quote_event ~ask:"101" () ]) + |> ok + in + (match T.Execution.next cursor ~oms |> ok with + | T.Execution.Triggered (order_id, triggered_at, 2L, _) -> + Alcotest.(check string) + "triggered order" + (T.Id.Order.to_string order.id) + (T.Id.Order.to_string order_id); + Alcotest.(check string) + "quote trigger uses event time" "2026-01-03T14:30:01.000000Z" + (T.Codec.ptime_to_string triggered_at) + | _ -> Alcotest.fail "stop was not triggered by observable quote"); + let other_event = + let event_at = market_event_time 1 in + T.Market_event.quote ~instrument_id:(instrument_id "other") ~event_at + ~available_at:event_at ~received_at:event_at ~ingest_sequence:1L + ~bid_price:(price "99") ~bid_quantity:(quantity "1") + ~ask_price:(price "101") ~ask_quantity:(quantity "1") + |> ok + in + Alcotest.(check bool) + "unknown event instrument rejected" true + (Result.is_error + (T.Execution.start_slice_quote_trade (quote_trade_execution ()) + ~instruments:[ instrument () ] + ~oms + (quote_trade_slice [ other_event ]))); + let old_at = timestamp "2026-01-02T14:30:00Z" in + let old_event = + T.Market_event.trade + ~instrument_id:(instrument_id "test-equity") + ~event_at:old_at ~available_at:old_at ~received_at:old_at + ~ingest_sequence:1L ~price:(price "100") ~quantity:(quantity "1") + ~aggressor_side:T.Market_event.Unknown + |> ok + in + Alcotest.(check bool) + "event outside slice rejected" true + (Result.is_error + (T.Execution.start_slice_quote_trade (quote_trade_execution ()) + ~instruments:[ instrument () ] + ~oms + (quote_trade_slice [ old_event ]))) + +let order_book_walks_depth_and_rejects_inconsistent_updates () = + let oms, order = oms_with_order (request ()) in + let snapshot = + book_snapshot ~asks:[ book_level "101" "4"; book_level "102" "6" ] () + in + let cursor = + T.Execution.start_slice_order_book (order_book_execution ()) + ~instruments:[ instrument () ] + ~oms + (order_book_slice [ snapshot ]) + |> ok + in + let first, advance = + match T.Execution.next cursor ~oms |> ok with + | T.Execution.Proposed (proposal, advance) -> (proposal, advance) + | _ -> Alcotest.fail "best ask did not produce a fill" + in + Alcotest.check quantity_testable "first level quantity" (quantity "4") + first.quantity; + Alcotest.check price_testable "best ask first" (price "101") first.price; + Alcotest.(check bool) + "over-consumption rejected" true + (Result.is_error (advance (quantity "5"))); + Alcotest.(check bool) + "negative application rejected" true + (Result.is_error (advance (quantity "-1"))); + Alcotest.(check bool) + "off-lot application rejected" true + (Result.is_error (advance (quantity "0.5"))); + let cursor = advance first.quantity |> ok in + let applied = + fill ~quantity_value:"4" ~price_value:"101" ~executed_at:first.executed_at + order + in + let oms, _ = T.Oms.apply_fill oms applied |> ok in + (match T.Execution.next cursor ~oms |> ok with + | T.Execution.Proposed (second, _) -> + Alcotest.check quantity_testable "second level quantity" (quantity "6") + second.quantity; + Alcotest.check price_testable "second ask follows" (price "102") + second.price + | _ -> Alcotest.fail "second ask did not produce a fill"); + let fok_oms, _ = + oms_with_order (request ~kind:T.Order.Market ~time_in_force:T.Order.Fok ()) + in + let fok_cursor = + T.Execution.start_slice_order_book (order_book_execution ()) + ~instruments:[ instrument () ] + ~oms:fok_oms + (order_book_slice [ snapshot ]) + |> ok + in + (match T.Execution.next fok_cursor ~oms:fok_oms |> ok with + | T.Execution.Proposed (proposal, _) -> + Alcotest.check quantity_testable "FOK sees total book depth" + (quantity "4") proposal.quantity + | _ -> Alcotest.fail "FOK ignored sufficient multi-level depth"); + let rejected events depth = + Result.is_error + (T.Execution.start_slice_order_book + (order_book_execution ~max_depth_levels:depth ()) + ~instruments:[ instrument () ] + ~oms (order_book_slice events)) + in + Alcotest.(check bool) + "sequence gap rejected" true + (rejected [ book_snapshot (); book_set ~sequence:3L () ] 10); + Alcotest.(check bool) + "missing delete rejected" true + (rejected [ book_snapshot (); book_delete ~price_value:"98" () ] 10); + Alcotest.(check bool) + "ask delete is applied" true + (Result.is_ok + (T.Execution.start_slice_order_book (order_book_execution ()) + ~instruments:[ instrument () ] + ~oms:T.Oms.empty + (order_book_slice + [ + book_snapshot (); + book_delete ~side:T.Order_book_event.Ask ~price_value:"101" (); + ]))); + Alcotest.(check bool) + "crossing update rejected" true + (rejected [ book_snapshot (); book_set ~price_value:"102" () ] 10); + Alcotest.(check bool) + "depth cap rejected" true + (rejected + [ book_snapshot ~bids:[ book_level "99" "1"; book_level "98" "1" ] () ] + 1); + Alcotest.(check bool) + "update before snapshot rejected" true + (rejected [ book_set () ] 10); + Alcotest.(check bool) + "duplicate snapshot rejected" true + (rejected [ book_snapshot (); book_snapshot ~sequence:2L ~second:2 () ] 10); + Alcotest.(check bool) + "trade beyond displayed depth rejected" true + (rejected + [ + book_snapshot ~bids:[ book_level "99" "2" ] (); + book_trade ~quantity_value:"3" (); + ] + 10); + let sell_oms, _ = oms_with_order (request ~side:T.Order.Sell ()) in + let sell_cursor = + T.Execution.start_slice_order_book (order_book_execution ()) + ~instruments:[ instrument () ] + ~oms:sell_oms + (order_book_slice + [ book_snapshot ~bids:[ book_level "99" "4"; book_level "98" "6" ] () ]) + |> ok + in + (match T.Execution.next sell_cursor ~oms:sell_oms |> ok with + | T.Execution.Proposed (proposal, advance) -> ( + Alcotest.check price_testable "sell walks best bid first" (price "99") + proposal.price; + let cursor = advance proposal.quantity |> ok in + let sell_order = T.Oms.find sell_oms proposal.order_id |> Option.get in + let applied = + fill ~quantity_value:"4" ~price_value:"99" + ~executed_at:proposal.executed_at sell_order + in + let sell_oms, _ = T.Oms.apply_fill sell_oms applied |> ok in + match T.Execution.next cursor ~oms:sell_oms |> ok with + | T.Execution.Proposed (next, _) -> + Alcotest.check price_testable "sell consumes next bid" (price "98") + next.price + | _ -> Alcotest.fail "second bid did not produce a sell fill") + | _ -> Alcotest.fail "best bid did not produce a sell fill"); + let added_oms, _ = + oms_with_order (request ~kind:(T.Order.Limit (price "102")) ()) + in + let added_cursor = + T.Execution.start_slice_order_book (order_book_execution ()) + ~instruments:[ instrument () ] + ~oms:added_oms + (order_book_slice + [ + book_snapshot ~asks:[ book_level "105" "10" ] (); + book_set ~side:T.Order_book_event.Ask ~price_value:"101" + ~quantity_value:"3" (); + ]) + |> ok + in + (match T.Execution.next added_cursor ~oms:added_oms |> ok with + | T.Execution.Proposed (proposal, advance) -> ( + Alcotest.check quantity_testable "added ask is bounded" (quantity "3") + proposal.quantity; + Alcotest.check price_testable "added ask becomes marketable" (price "101") + proposal.price; + let cursor = advance (quantity "2") |> ok in + let added_order = T.Oms.find added_oms proposal.order_id |> Option.get in + let applied = + fill ~quantity_value:"2" ~price_value:"101" + ~executed_at:proposal.executed_at added_order + in + let added_oms, _ = T.Oms.apply_fill added_oms applied |> ok in + match T.Execution.next cursor ~oms:added_oms |> ok with + | T.Execution.Proposed (remainder, _) -> + Alcotest.check quantity_testable "added ask remainder" (quantity "1") + remainder.quantity + | _ -> Alcotest.fail "added ask remainder did not execute") + | _ -> Alcotest.fail "added ask did not produce a fill"); + let passive_sell_oms, _ = + oms_with_order + (request ~side:T.Order.Sell ~kind:(T.Order.Limit (price "101")) ()) + in + let buy_trade_cursor = + T.Execution.start_slice_order_book (order_book_execution ()) + ~instruments:[ instrument () ] + ~oms:passive_sell_oms + (order_book_slice + [ + book_snapshot ~asks:[ book_level "101" "5" ] (); + book_trade ~aggressor_side:T.Market_event.Buy ~price_value:"101" + ~quantity_value:"2" (); + ]) + |> ok + in + (match T.Execution.next buy_trade_cursor ~oms:passive_sell_oms |> ok with + | T.Execution.Finished _ -> () + | _ -> Alcotest.fail "buy trade should remain behind displayed ask queue"); + let unknown_trade_cursor = + T.Execution.start_slice_order_book (order_book_execution ()) + ~instruments:[ instrument () ] + ~oms:T.Oms.empty + (order_book_slice + [ + book_snapshot (); + book_trade ~aggressor_side:T.Market_event.Unknown + ~quantity_value:"100" (); + ]) + in + Alcotest.(check bool) + "unknown trade does not consume book" true + (Result.is_ok unknown_trade_cursor); + let triggered_stop side trigger = + let oms, order = + oms_with_order + (request ~side + ~kind:(T.Order.Stop (price trigger)) + ~time_in_force:T.Order.Gtc ()) + in + let cursor = + T.Execution.start_slice_order_book (order_book_execution ()) + ~instruments:[ instrument () ] + ~oms + (order_book_slice [ book_snapshot () ]) + |> ok + in + match T.Execution.next cursor ~oms |> ok with + | T.Execution.Triggered (order_id, triggered_at, 2L, _) -> + Alcotest.(check string) + "order-book stop identity" + (T.Id.Order.to_string order.id) + (T.Id.Order.to_string order_id); + Alcotest.(check string) + "order-book stop event time" "2026-01-03T14:30:01.000000Z" + (T.Codec.ptime_to_string triggered_at) + | _ -> Alcotest.fail "order-book snapshot did not trigger stop" + in + triggered_stop T.Order.Buy "100"; + triggered_stop T.Order.Sell "100"; + let waiting_stop_oms, _ = + oms_with_order + (request ~kind:(T.Order.Stop (price "200")) ~time_in_force:T.Order.Gtc ()) + in + let waiting_stop_cursor = + T.Execution.start_slice_order_book (order_book_execution ()) + ~instruments:[ instrument () ] + ~oms:waiting_stop_oms + (order_book_slice [ book_snapshot () ]) + |> ok + in + (match T.Execution.next waiting_stop_cursor ~oms:waiting_stop_oms |> ok with + | T.Execution.Finished _ -> () + | _ -> Alcotest.fail "untriggered order-book stop should remain dormant"); + let shallow_fok_oms, _ = + oms_with_order (request ~kind:T.Order.Market ~time_in_force:T.Order.Fok ()) + in + let shallow_fok_cursor = + T.Execution.start_slice_order_book (order_book_execution ()) + ~instruments:[ instrument () ] + ~oms:shallow_fok_oms + (order_book_slice [ book_snapshot ~asks:[ book_level "101" "3" ] () ]) + |> ok + in + (match T.Execution.next shallow_fok_cursor ~oms:shallow_fok_oms |> ok with + | T.Execution.Finished _ -> () + | _ -> Alcotest.fail "FOK should reject insufficient order-book depth"); + Alcotest.(check bool) + "book model requires book configuration" true + (Result.is_error + (T.Execution.start_slice_order_book (quote_trade_execution ()) + ~instruments:[ instrument () ] + ~oms:T.Oms.empty + (order_book_slice [ book_snapshot () ]))); + Alcotest.(check bool) + "unknown book instrument rejected" true + (Result.is_error + (T.Execution.start_slice_order_book (order_book_execution ()) + ~instruments:[ instrument ~id:"other" () ] + ~oms:T.Oms.empty + (order_book_slice [ book_snapshot () ]))); + Alcotest.(check bool) + "snapshot coverage must be complete" true + (Result.is_error + (T.Execution.start_slice_order_book (order_book_execution ()) + ~instruments:[ instrument (); instrument ~id:"other" () ] + ~oms:T.Oms.empty + (order_book_slice [ book_snapshot () ]))); + Alcotest.(check bool) + "off-tick book level rejected" true + (Result.is_error + (T.Execution.start_slice_order_book (order_book_execution ()) + ~instruments:[ instrument ~tick_size:"1" () ] + ~oms:T.Oms.empty + (order_book_slice + [ + book_snapshot + ~bids:[ book_level "99.5" "2" ] + ~asks:[ book_level "101" "2" ] + (); + ]))); + Alcotest.(check bool) + "off-lot book level rejected" true + (Result.is_error + (T.Execution.start_slice_order_book (order_book_execution ()) + ~instruments:[ instrument ~lot_size:"2" () ] + ~oms:T.Oms.empty + (order_book_slice + [ + book_snapshot + ~bids:[ book_level "99" "1" ] + ~asks:[ book_level "101" "2" ] + (); + ]))); + let old_at = timestamp "2026-01-02T14:30:00Z" in + let old_snapshot = + T.Order_book_event.snapshot + ~instrument_id:(instrument_id "test-equity") + ~event_at:old_at ~available_at:old_at ~received_at:old_at + ~ingest_sequence:1L ~book_sequence:1L + ~bids:[ book_level "99" "1" ] + ~asks:[ book_level "101" "1" ] + |> ok + in + Alcotest.(check bool) + "book event outside slice rejected" true + (Result.is_error + (T.Execution.start_slice_order_book (order_book_execution ()) + ~instruments:[ instrument () ] + ~oms:T.Oms.empty + (order_book_slice [ old_snapshot ]))); + Alcotest.(check bool) + "order-book depth must be positive" true + (Result.is_error + (T.Execution.create_order_book ~participation_bps:10_000 + ~fee_schedules:(T.Execution.fee_schedules (conservative_execution ())) + ~max_depth_levels:0)); + Alcotest.(check bool) + "order-book depth is capped" true + (Result.is_error + (T.Execution.create_order_book ~participation_bps:10_000 + ~fee_schedules:(T.Execution.fee_schedules (conservative_execution ())) + ~max_depth_levels:1025)) + +let conservative_limit_models_diverge () = + let engine = conservative_execution () in + let limit = T.Order.Limit (price "100") in + let touch = + market_slice + ~bars:[ bar ~open_price:"105" ~high_price:"110" ~low_price:"100" 2L ] + 2L + in + (match + conservative_step T.Execution.start_slice_next_open ~kind:limit + ~slice:touch engine + with + | T.Execution.Finished _ -> () + | _ -> Alcotest.fail "next-open model filled an intrabar touch"); + (match + conservative_step T.Execution.start_slice_adverse_touch ~kind:limit + ~slice:touch engine + with + | T.Execution.Finished _ -> () + | _ -> Alcotest.fail "adverse-touch model filled without trade-through"); + let traded_through = + market_slice + ~bars:[ bar ~open_price:"105" ~high_price:"110" ~low_price:"99.99" 2L ] + 2L + in + match + conservative_step T.Execution.start_slice_adverse_touch ~kind:limit + ~slice:traded_through engine + with + | T.Execution.Proposed (proposal, _) -> + Alcotest.check price_testable "one-tick adverse reference" (price "99.99") + proposal.price + | _ -> Alcotest.fail "adverse trade-through did not fill" + +let conservative_costs_are_tick_aligned_and_attributed () = + let engine = + conservative_execution ~half_spread_bps:10 ~impact_coefficient_bps:100 () + in + match + conservative_step T.Execution.start_slice_next_open + ~slice: + (market_slice + ~bars:[ bar ~open_price:"100" ~volume:(Some "100") 2L ] + 2L) + engine + with + | T.Execution.Proposed (proposal, _) -> + Alcotest.check price_testable "spread and impact final price" + (price "100.2") proposal.price; + let attribution = Option.get proposal.price_attribution in + Alcotest.check price_testable "reference" (price "100") + attribution.reference_price; + Alcotest.check money_testable "spread" (money "0.1") + attribution.spread_adjustment; + Alcotest.check money_testable "impact" (money "0.1") + attribution.impact_adjustment; + Alcotest.check price_testable "attributed final" proposal.price + attribution.final_price + | _ -> Alcotest.fail "expected conservative market fill" + +let conservative_missing_volume_policy_is_explicit () = + let missing = market_slice ~bars:[ bar ~volume:None 2L ] 2L in + let rejecting = conservative_execution ~impact_coefficient_bps:100 () in + let oms, _ = oms_with_order (request ()) in + let cursor = + T.Execution.start_slice_next_open rejecting + ~instruments:[ instrument () ] + ~oms missing + |> ok + in + Alcotest.(check bool) + "missing volume rejected" true + (Result.is_error (T.Execution.next cursor ~oms)); + let zero = + conservative_execution ~half_spread_bps:10 ~impact_coefficient_bps:100 + ~missing_volume_policy:T.Execution.Zero_impact () + in + match + conservative_step T.Execution.start_slice_next_open ~slice:missing zero + with + | T.Execution.Proposed (proposal, _) -> + Alcotest.check price_testable "zero-impact fallback keeps spread" + (price "100.1") proposal.price + | _ -> Alcotest.fail "zero-impact fallback did not fill" + +let conservative_configuration_is_bounded () = + let valid = conservative_execution () in + let schedules = T.Execution.fee_schedules valid in + let create half_spread_bps impact_coefficient_bps = + T.Execution.create_conservative ~participation_bps:10_000 + ~fee_schedules:schedules ~half_spread_bps ~impact_coefficient_bps + ~missing_volume_policy:T.Execution.Reject_missing_volume + in + List.iter + (fun (spread, impact) -> + Alcotest.(check bool) + "out-of-range cost rejected" true + (Result.is_error (create spread impact))) + [ (-1, 0); (10_001, 0); (0, -1); (0, 10_001) ]; + Alcotest.(check bool) + "participation bound enforced" true + (Result.is_error + (T.Execution.create ~participation_bps:(-1) ~fee_schedules:schedules)); + let schedule = List.hd schedules in + Alcotest.(check bool) + "duplicate fee schedules rejected" true + (Result.is_error + (T.Execution.create ~participation_bps:10_000 + ~fee_schedules:[ schedule; schedule ])); + Alcotest.(check bool) + "missing instrument fee schedule rejected" true + (Result.is_error + (T.Execution.calculate_fee valid + ~instrument:(instrument ~id:"other-equity" ~symbol:"OTHER" ()) + ~notional:(money "100") ~quantity:(quantity "1") + ~liquidity:T.Fee_schedule.Taker + ~fx_rates:[ ("USD", price "1") ])) + +let conservative_sell_costs_and_limit_protection () = + let engine = + conservative_execution ~half_spread_bps:10 ~impact_coefficient_bps:100 () + in + (match + conservative_step T.Execution.start_slice_next_open ~side:T.Order.Sell + ~slice: + (market_slice + ~bars:[ bar ~open_price:"100" ~volume:(Some "100") 2L ] + 2L) + engine + with + | T.Execution.Proposed (proposal, _) -> + Alcotest.check price_testable "sell costs reduce execution price" + (price "99.8") proposal.price; + let attribution = Option.get proposal.price_attribution in + Alcotest.check money_testable "sell spread attribution" (money "0.1") + attribution.spread_adjustment; + Alcotest.check money_testable "sell impact attribution" (money "0.1") + attribution.impact_adjustment + | _ -> Alcotest.fail "expected conservative sell fill"); + let buy_limit = T.Order.Limit (price "100") in + match + conservative_step T.Execution.start_slice_next_open ~kind:buy_limit + ~slice: + (market_slice + ~bars:[ bar ~open_price:"100" ~volume:(Some "100") 2L ] + 2L) + engine + with + | T.Execution.Finished _ -> ( + let buy_with_room = T.Order.Limit (price "101") in + (match + conservative_step T.Execution.start_slice_next_open ~kind:buy_with_room + ~slice: + (market_slice + ~bars:[ bar ~open_price:"100" ~volume:(Some "100") 2L ] + 2L) + engine + with + | T.Execution.Proposed (proposal, _) -> + Alcotest.check price_testable "cost-adjusted buy respects limit" + (price "100.2") proposal.price + | _ -> Alcotest.fail "buy with limit room did not fill"); + let sell_limit = T.Order.Limit (price "100") in + (match + conservative_step T.Execution.start_slice_next_open ~kind:sell_limit + ~side:T.Order.Sell + ~slice: + (market_slice + ~bars:[ bar ~open_price:"100" ~volume:(Some "100") 2L ] + 2L) + engine + with + | T.Execution.Finished _ -> () + | _ -> Alcotest.fail "cost-adjusted fill violated sell limit"); + let sell_with_room = T.Order.Limit (price "99") in + match + conservative_step T.Execution.start_slice_next_open ~kind:sell_with_room + ~side:T.Order.Sell + ~slice: + (market_slice + ~bars:[ bar ~open_price:"100" ~volume:(Some "100") 2L ] + 2L) + engine + with + | T.Execution.Proposed (proposal, _) -> + Alcotest.check price_testable "cost-adjusted sell respects limit" + (price "99.8") proposal.price + | _ -> Alcotest.fail "sell with limit room did not fill") + | _ -> Alcotest.fail "cost-adjusted fill violated buy limit" + +let conservative_adverse_sell_requires_trade_through () = + let engine = conservative_execution () in + let limit = T.Order.Limit (price "100") in + let touch = + market_slice + ~bars: + [ + bar ~open_price:"95" ~high_price:"100" ~low_price:"90" + ~close_price:"95" 2L; + ] + 2L + in + (match + conservative_step T.Execution.start_slice_adverse_touch ~kind:limit + ~side:T.Order.Sell ~slice:touch engine + with + | T.Execution.Finished _ -> () + | _ -> Alcotest.fail "sell filled without one-tick trade-through"); + let traded_through = + market_slice + ~bars: + [ + bar ~open_price:"95" ~high_price:"100.01" ~low_price:"90" + ~close_price:"95" 2L; + ] + 2L + in + match + conservative_step T.Execution.start_slice_adverse_touch ~kind:limit + ~side:T.Order.Sell ~slice:traded_through engine + with + | T.Execution.Proposed (proposal, _) -> + Alcotest.check price_testable "sell adverse reference" (price "100.01") + proposal.price + | _ -> Alcotest.fail "sell trade-through did not fill" + let single_order_match ?(side = T.Order.Buy) ?(kind = T.Order.Market) ?(quantity_value = "10") ?(slice = market_slice 2L) () = let request = request ~side ~kind ~quantity_value () in @@ -348,7 +1189,8 @@ let cursor_reads_current_oms_and_preserves_capacity () = Alcotest.check order_id_testable "first proposal" first.id proposed.order_id; advance proposed.quantity |> ok - | T.Execution.Finished _ -> Alcotest.fail "expected first proposal" + | T.Execution.Finished _ | T.Execution.Triggered _ -> + Alcotest.fail "expected first proposal" in let oms, _ = T.Oms.cancel oms second.id |> ok in let cursor = @@ -359,11 +1201,13 @@ let cursor_reads_current_oms_and_preserves_capacity () = Alcotest.check quantity_testable "unused capacity reaches third order" (quantity "1") proposed.quantity; advance proposed.quantity |> ok - | T.Execution.Finished _ -> Alcotest.fail "expected third-order proposal" + | T.Execution.Finished _ | T.Execution.Triggered _ -> + Alcotest.fail "expected third-order proposal" in match T.Execution.next cursor ~oms |> ok with | T.Execution.Finished _ -> () - | T.Execution.Proposed _ -> Alcotest.fail "expected completed cursor" + | T.Execution.Proposed _ | T.Execution.Triggered _ -> + Alcotest.fail "expected completed cursor" let fills_respect_lot_size () = let configured = instrument ~lot_size:"10" () in @@ -403,6 +1247,30 @@ let incomplete_market_slice_returns_error () = let tests = [ + Alcotest.test_case "quote replay consumes displayed liquidity" `Quick + quote_trade_consumes_displayed_liquidity; + Alcotest.test_case "passive trade requires aggressor evidence" `Quick + quote_trade_passive_fills_require_aggressor_evidence; + Alcotest.test_case "quote replay sell paths" `Quick + quote_trade_sell_paths_use_bid_and_buy_aggressors; + Alcotest.test_case "quote replay limits, FOK, and continuations" `Quick + quote_trade_limits_fok_and_continuations; + Alcotest.test_case "quote replay stops and boundaries" `Quick + quote_trade_stop_and_event_boundaries; + Alcotest.test_case "order book walks depth and rejects inconsistent updates" + `Quick order_book_walks_depth_and_rejects_inconsistent_updates; + Alcotest.test_case "conservative limit models diverge" `Quick + conservative_limit_models_diverge; + Alcotest.test_case "conservative costs are attributed" `Quick + conservative_costs_are_tick_aligned_and_attributed; + Alcotest.test_case "conservative missing-volume policy" `Quick + conservative_missing_volume_policy_is_explicit; + Alcotest.test_case "conservative configuration bounds" `Quick + conservative_configuration_is_bounded; + Alcotest.test_case "conservative sell costs and limits" `Quick + conservative_sell_costs_and_limit_protection; + Alcotest.test_case "conservative adverse sell" `Quick + conservative_adverse_sell_requires_trade_through; Alcotest.test_case "order waits for later slice" `Quick order_waits_for_later_slice; Alcotest.test_case "order waits for causal slice time" `Quick diff --git a/test/test_fee_schedules.ml b/test/test_fee_schedules.ml new file mode 100644 index 0000000..6d6b6f4 --- /dev/null +++ b/test/test_fee_schedules.ml @@ -0,0 +1,135 @@ +open Test_support +module T = Trading_engine + +let component ?(currency = "USD") ?(rounding = T.Fee_schedule.Up) + ?(applies_to = T.Fee_schedule.Any) name basis = + T.Fee_schedule.create_component ~name ~currency ~basis ~rounding + ~applicability:applies_to + |> ok + +let schedule ?(minimum = None) ?(maximum = None) components = + T.Fee_schedule.create ~schedule_id:"test-fees-v1" + ~instrument_id:(instrument_id "test-equity") + ~settlement_currency:"USD" ~minimum ~maximum ~components + |> ok + +let calculate schedule ~liquidity ~notional_value ~quantity_value = + T.Fee_schedule.calculate schedule ~quote_currency:"USD" + ~notional:(money notional_value) ~quantity:(quantity quantity_value) + ~liquidity + ~fx_rates:[ ("USD", price "1"); ("EUR", price "1.2") ] + |> ok + +let components_minimums_caps_and_fx () = + let fees = + schedule + ~minimum:(Some (money "0.5")) + ~maximum:(Some (money "2")) + [ + component "broker" (T.Fee_schedule.Fixed (money "0.1")); + component ~currency:"EUR" "exchange" (T.Fee_schedule.Notional_bps 10); + component ~rounding:T.Fee_schedule.Nearest + ~applies_to:T.Fee_schedule.Maker_only "maker_rebate" + (T.Fee_schedule.Notional_bps (-5)); + component "regulatory" (T.Fee_schedule.Per_unit (money "0.01")); + ] + in + let taker_components, taker = + calculate fees ~liquidity:T.Fee_schedule.Taker ~notional_value:"100" + ~quantity_value:"10" + in + Alcotest.check money_testable "minimum applied after FX" (money "0.5") taker; + Alcotest.(check int) + "three charges and minimum adjustment" 4 + (List.length taker_components); + let maker_components, maker = + calculate fees ~liquidity:T.Fee_schedule.Maker ~notional_value:"100" + ~quantity_value:"10" + in + Alcotest.check money_testable "rebate still observes minimum" (money "0.5") + maker; + Alcotest.(check bool) + "maker attribution includes rebate" true + (List.exists + (fun component -> + String.equal component.T.Fee_schedule.name "maker_rebate" + && T.Scalar.Money.compare component.quote_amount T.Scalar.Money.zero + < 0) + maker_components); + let capped = + schedule + ~maximum:(Some (money "2")) + [ component "broker" (T.Fee_schedule.Fixed (money "3")) ] + in + let capped_components, capped_total = + calculate capped ~liquidity:T.Fee_schedule.Taker ~notional_value:"100" + ~quantity_value:"1" + in + Alcotest.check money_testable "cap" (money "2") capped_total; + Alcotest.(check bool) + "cap is attributed" true + (List.exists + (fun component -> + String.equal component.T.Fee_schedule.kind "maximum_adjustment") + capped_components) + +let fragmented_fills_pay_per_fill_minimum () = + let fees = + schedule + ~minimum:(Some (money "0.5")) + [ component "exchange" (T.Fee_schedule.Notional_bps 1) ] + in + let _, whole = + calculate fees ~liquidity:T.Fee_schedule.Taker ~notional_value:"100" + ~quantity_value:"10" + in + let _, fragment = + calculate fees ~liquidity:T.Fee_schedule.Taker ~notional_value:"50" + ~quantity_value:"5" + in + let fragmented = T.Scalar.Money.add fragment fragment |> ok in + Alcotest.check money_testable "whole minimum" (money "0.5") whole; + Alcotest.check money_testable "two fill minimums" (money "1") fragmented + +let rebate_settles_and_is_attributed () = + let fees = + schedule + [ + component ~rounding:T.Fee_schedule.Nearest "maker_rebate" + (T.Fee_schedule.Notional_bps (-10)); + ] + in + let fee_components, fee = + calculate fees ~liquidity:T.Fee_schedule.Maker ~notional_value:"100" + ~quantity_value:"1" + in + Alcotest.check money_testable "negative rebate" (money "-0.1") fee; + let request = request ~quantity_value:"1" () in + let fill = + T.Fill.create ~id:(fill_id "rebate-fill") + ~order_id:(order_id "rebate-order") ~instrument_id:request.instrument_id + ~quote_currency:"USD" ~side:T.Order.Buy ~quantity:(quantity "1") + ~price:(price "100") ~fee ~fee_components + ~executed_at:(timestamp "2026-01-03T14:30:00Z") + ~slice_sequence:1L + |> ok + in + let account = T.Account.apply_fill (test_account ()) fill |> ok in + Alcotest.check money_testable "rebate increases cash" (money "9900.1") + (T.Account.cash account "USD" |> Option.get); + let position = T.Account.position account request.instrument_id in + Alcotest.check money_testable "signed execution fee" (money "-0.1") + position.execution_fees; + Alcotest.(check int) + "position component attribution" 1 + (List.length position.execution_fee_components) + +let tests = + [ + Alcotest.test_case "components, FX, minimums, and caps" `Quick + components_minimums_caps_and_fx; + Alcotest.test_case "fragmented minimums" `Quick + fragmented_fills_pay_per_fill_minimum; + Alcotest.test_case "rebate accounting attribution" `Quick + rebate_settles_and_is_attributed; + ] diff --git a/test/test_financing.ml b/test/test_financing.ml new file mode 100644 index 0000000..fa40cb3 --- /dev/null +++ b/test/test_financing.ml @@ -0,0 +1,465 @@ +open Test_support +module T = Trading_engine +module Runner = T.Engine.Make (T.Scripted_strategy) + +let policy ?(day_count = T.Financing.Actual_360) + ?(compounding = T.Financing.Simple) + ?(borrow_missing_data = T.Financing.Reject) + ?(cash_missing_data = T.Financing.Reject) + ?(locate_policy = T.Financing.Clip_fill) + ?(recall_policy = T.Financing.Close_out) () = + T.Financing.policy ~day_count ~compounding ~borrow_missing_data + ~cash_missing_data ~locate_policy ~recall_policy + +let one_day = + Ptime.diff + (timestamp "2026-01-02T00:00:00Z") + (timestamp "2026-01-01T00:00:00Z") + +let explicit_accrual_policies () = + let principal = money "36000" in + let simple = + T.Financing.accrue (policy ()) ~principal ~annual_rate_bps:10_000 one_day + |> ok + in + Alcotest.check money_testable "actual/360 one-day interest" (money "100") + simple; + let two_days = Ptime.Span.add one_day one_day in + let daily = + T.Financing.accrue + (policy ~compounding:T.Financing.Daily ()) + ~principal ~annual_rate_bps:10_000 two_days + |> ok + in + Alcotest.check money_testable "daily capitalization" (money "200.277778") + daily; + let rebate = + T.Financing.accrue (policy ()) ~principal ~annual_rate_bps:(-1000) one_day + |> ok + in + Alcotest.check money_testable "negative rate" (money "-10") rebate + +let accrual_boundaries_and_policy_names () = + let actual_365 = + T.Financing.accrue + (policy ~day_count:T.Financing.Actual_365 ()) + ~principal:(money "36500") ~annual_rate_bps:10_000 one_day + |> ok + in + Alcotest.check money_testable "actual/365 one-day interest" (money "100") + actual_365; + Alcotest.(check bool) + "invalid accrual rate" true + (Result.is_error + (T.Financing.accrue (policy ()) ~principal:(money "1") + ~annual_rate_bps:1_000_001 one_day)); + Alcotest.(check bool) + "negative accrual interval" true + (Result.is_error + (T.Financing.accrue (policy ()) ~principal:(money "1") + ~annual_rate_bps:100 (Ptime.Span.neg one_day))); + let year = + Ptime.diff + (timestamp "2027-01-01T00:00:00Z") + (timestamp "2026-01-01T00:00:00Z") + in + Alcotest.(check bool) + "accrual overflow" true + (Result.is_error + (T.Financing.accrue + (policy ~day_count:T.Financing.Actual_365 ()) + ~principal:(T.Scalar.Money.of_micros Int64.max_int) + ~annual_rate_bps:1_000_000 year)); + Alcotest.(check (list string)) + "policy names" + [ + "actual_365"; + "actual_360"; + "simple"; + "daily"; + "reject"; + "zero"; + "reject_order"; + "clip_fill"; + "reject_new_shorts"; + "close_out"; + ] + [ + T.Financing.day_count_to_string T.Financing.Actual_365; + T.Financing.day_count_to_string T.Financing.Actual_360; + T.Financing.compounding_to_string T.Financing.Simple; + T.Financing.compounding_to_string T.Financing.Daily; + T.Financing.missing_data_to_string T.Financing.Reject; + T.Financing.missing_data_to_string T.Financing.Zero; + T.Financing.locate_policy_to_string T.Financing.Reject_order; + T.Financing.locate_policy_to_string T.Financing.Clip_fill; + T.Financing.recall_policy_to_string T.Financing.Reject_new_shorts; + T.Financing.recall_policy_to_string T.Financing.Close_out; + ] + +let cash_interest_is_ledger_attributed () = + let account = + test_account ~initial_cash:[ ("USD", money "100"); ("EUR", money "0") ] () + in + let account = + T.Account.apply_cash_interest account ~currency:"USD" ~interest:(money "1") + |> ok + in + let account = + T.Account.apply_cash_interest account ~currency:"EUR" + ~interest:(money "-0.5") + |> ok + in + let valuation = + account_value ~instruments:[] + ~fx_rates:[ ("USD", price "1"); ("EUR", price "2") ] + account ~marks:[] + in + Alcotest.check money_testable "base interest" (money "0") + valuation.cash_interest; + let eur = + List.find + (fun (row : T.Account.cash_attribution) -> + String.equal row.currency "EUR") + valuation.cash_balances + in + Alcotest.check money_testable "native debit interest" (money "-0.5") + eur.interest; + Alcotest.check money_testable "base debit interest" (money "-1") + eur.base_interest; + Alcotest.check money_testable "cash interest contributes to realized P&L" + (money "0") valuation.realized_pnl; + let negative_account = test_account ~initial_cash:[ ("USD", money "0") ] () in + let negative_account = + T.Account.apply_cash_interest negative_account ~currency:"USD" + ~interest:(money "-1") + |> ok + in + let negative_valuation = + account_value ~instruments:[] + ~fx_rates:[ ("USD", price "1") ] + negative_account ~marks:[] + in + Alcotest.check money_testable "debit interest can produce negative equity" + (money "-1") negative_valuation.equity; + Alcotest.check money_testable "negative equity retains realized attribution" + (money "-1") negative_valuation.realized_pnl + +let financing_slice ?(borrow_observations = []) ?(cash_rate_observations = []) + sequence = + let day = day sequence in + let start_at = timestamp (Printf.sprintf "2026-01-%02dT14:30:00Z" day) in + let end_at = timestamp (Printf.sprintf "2026-01-%02dT21:00:00Z" day) in + let available_at = timestamp (Printf.sprintf "2026-01-%02dT21:00:01Z" day) in + let received_at = timestamp (Printf.sprintf "2026-01-%02dT21:00:02Z" day) in + T.Market_slice.create ~slice_sequence:sequence ~start_at ~end_at ~available_at + ~received_at + ~bars:[ bar sequence ] + ~fx_rates:[ fx_mark () ] + ~corporate_actions:[] ~borrow_observations ~cash_rate_observations + ~settlement_failures:[] ~lifecycle_events:[] ~market_events:[] + ~order_book_events:[] + |> ok + +let borrow_observation ?(available = "5") ?(rate = 3600) ?(recalled = false) + effective_at = + T.Financing.borrow_observation + ~instrument_id:(instrument_id "test-equity") + ~effective_at ~available_quantity:(quantity available) ~annual_rate_bps:rate + ~recalled + |> ok + +let cash_rate effective_at = + T.Financing.cash_rate_observation ~currency:"USD" ~effective_at + ~credit_rate_bps:0 ~debit_rate_bps:3600 + |> ok + +let financing_config financing = + T.Engine.config ~contract_version:T.Contract.version ~risk:(risk ()) + ~venue_calendars:[] + ~execution_model:(T.Execution_model.find "completed_bar_v1" |> ok) + ~execution:(execution ()) ~financing ~settlement:(settlement_policy ()) + ~max_internal_events:1000 + |> ok + +let empty_strategy () = T.Scripted_strategy.create [] |> ok + +let missing_data_policies_are_explicit () = + let cash_state = + Runner.create ~run_id:(run_id "missing-cash") ~scenario_sha256 + ~config:(financing_config (policy ())) + ~initial_portfolio:(initial_portfolio ~cash:[ ("USD", money "1000") ] ()) + ~strategy_state:(empty_strategy ()) + |> ok + in + let cash_error = + Runner.process_slice cash_state (financing_slice 1L) |> error + in + Alcotest.(check string) + "missing cash rate rejected" + "nonzero cash balance has no effective rate for currency USD" cash_error; + let short = + T.Initial_portfolio.position + ~instrument_id:(instrument_id "test-equity") + ~quantity:(quantity "-1") ~cost_basis:(money "-100") + ~realized_pnl:T.Scalar.Money.zero ~dividend_pnl:T.Scalar.Money.zero + ~execution_fees:T.Scalar.Money.zero ~borrow_fees:T.Scalar.Money.zero + |> ok + in + let initial_portfolio = + T.Initial_portfolio.create ~base_currency:"USD" + ~cash:[ ("USD", money "1100") ] + ~positions:[ short ] + ~marks:[ (instrument_id "test-equity", price "100") ] + ~fx_rates:[ ("USD", price "1") ] + |> ok + in + let borrow_state = + Runner.create ~run_id:(run_id "missing-borrow") ~scenario_sha256 + ~config:(financing_config (policy ~cash_missing_data:T.Financing.Zero ())) + ~initial_portfolio ~strategy_state:(empty_strategy ()) + |> ok + in + let borrow_error = + Runner.process_slice borrow_state (financing_slice 1L) |> error + in + Alcotest.(check string) + "missing borrow observation rejected" + "open short has no effective borrow observation for test-equity" + borrow_error + +let reject_order_policy_uses_current_locate () = + let target = + T.Strategy.Target_quantities + [ + T.Strategy. + { + instrument_id = instrument_id "test-equity"; + quantity = quantity "-10"; + }; + ] + in + let strategy_state = T.Scripted_strategy.create [ (1L, [ target ]) ] |> ok in + let financing = + policy ~borrow_missing_data:T.Financing.Zero + ~cash_missing_data:T.Financing.Zero + ~locate_policy:T.Financing.Reject_order () + in + let state = + Runner.create ~run_id:(run_id "reject-locate") ~scenario_sha256 + ~config:(financing_config financing) + ~initial_portfolio:(initial_portfolio ~cash:[ ("USD", money "1000") ] ()) + ~strategy_state + |> ok + in + let start_at = timestamp "2026-01-02T14:30:00Z" in + let state, events = + Runner.process_slice state + (financing_slice ~borrow_observations:[ borrow_observation start_at ] 1L) + |> ok + in + Alcotest.check quantity_testable "rejected order leaves position flat" + T.Scalar.Quantity.zero + (T.Account.position_quantity (Runner.account state) + (instrument_id "test-equity")); + Alcotest.(check bool) + "oversized locate order rejected" true + (List.exists + (fun event -> + match event.T.Audit.event with + | T.Audit.Order_rejected { status = T.Order.Rejected reason; _ } -> + String.equal reason "order exceeds effective borrow availability" + | _ -> false) + events) + +let zero_missing_data_and_recall_retention () = + let short = + T.Initial_portfolio.position + ~instrument_id:(instrument_id "test-equity") + ~quantity:(quantity "-1") ~cost_basis:(money "-100") + ~realized_pnl:T.Scalar.Money.zero ~dividend_pnl:T.Scalar.Money.zero + ~execution_fees:T.Scalar.Money.zero ~borrow_fees:T.Scalar.Money.zero + |> ok + in + let initial_portfolio = + T.Initial_portfolio.create ~base_currency:"USD" + ~cash:[ ("USD", money "1100") ] + ~positions:[ short ] + ~marks:[ (instrument_id "test-equity", price "100") ] + ~fx_rates:[ ("USD", price "1") ] + |> ok + in + let financing = + policy ~borrow_missing_data:T.Financing.Zero + ~cash_missing_data:T.Financing.Zero + ~recall_policy:T.Financing.Reject_new_shorts () + in + let state = + Runner.create ~run_id:(run_id "retain-recall") ~scenario_sha256 + ~config:(financing_config financing) + ~initial_portfolio ~strategy_state:(empty_strategy ()) + |> ok + in + let state, _ = Runner.process_slice state (financing_slice 1L) |> ok in + let recall_at = timestamp "2026-01-03T14:30:00Z" in + let cash_observation = + T.Financing.cash_rate_observation ~currency:"USD" ~effective_at:recall_at + ~credit_rate_bps:0 ~debit_rate_bps:0 + |> ok + in + let state, events = + Runner.process_slice state + (financing_slice + ~borrow_observations: + [ + borrow_observation ~available:"0" ~rate:(-100) ~recalled:true + recall_at; + ] + ~cash_rate_observations:[ cash_observation ] 2L) + |> ok + in + Alcotest.check quantity_testable "reject-new-shorts retains recalled position" + (quantity "-1") + (T.Account.position_quantity (Runner.account state) + (instrument_id "test-equity")); + Alcotest.(check bool) + "retained recall audited without close-out" true + (List.exists + (fun event -> + match event.T.Audit.event with + | T.Audit.Borrow_recall_received { close_out_quantity; _ } -> + T.Scalar.Quantity.is_zero close_out_quantity + | _ -> false) + events) + +let availability_clips_and_recall_closes () = + let target = + T.Strategy.Target_quantities + [ + T.Strategy. + { + instrument_id = instrument_id "test-equity"; + quantity = quantity "-10"; + }; + ] + in + let strategy_state = T.Scripted_strategy.create [ (1L, [ target ]) ] |> ok in + let configured_risk = risk () in + let financing = policy () in + let config = + T.Engine.config ~contract_version:T.Contract.version ~risk:configured_risk + ~venue_calendars:[] + ~execution_model:(T.Execution_model.find "completed_bar_v1" |> ok) + ~execution:(execution ()) ~financing ~settlement:(settlement_policy ()) + ~max_internal_events:1000 + |> ok + in + let state = + Runner.create ~run_id:(run_id "financing") ~scenario_sha256 ~config + ~initial_portfolio:(initial_portfolio ~cash:[ ("USD", money "1000") ] ()) + ~strategy_state + |> ok + in + let first_start = timestamp "2026-01-02T14:30:00Z" in + let state, _ = + Runner.process_slice state + (financing_slice + ~borrow_observations:[ borrow_observation first_start ] + ~cash_rate_observations:[ cash_rate first_start ] + 1L) + |> ok + in + let state, events = Runner.process_slice state (financing_slice 2L) |> ok in + let encoded = List.map T.Codec.audit_to_string events in + Alcotest.(check bool) + "availability audits serialize" true + (List.for_all (fun value -> String.length value > 0) encoded); + Alcotest.check quantity_testable "locate-limited short" (quantity "-5") + (T.Account.position_quantity (Runner.account state) + (instrument_id "test-equity")); + Alcotest.(check bool) + "availability clipping audited" true + (List.exists + (fun event -> + match event.T.Audit.event with + | T.Audit.Fill_clipped + { limit = T.Risk.Instrument_borrow_availability _; _ } -> + true + | _ -> false) + events); + let recall_start = timestamp "2026-01-04T14:30:00Z" in + let recall = borrow_observation ~available:"0" ~recalled:true recall_start in + let state, events = + Runner.process_slice state + (financing_slice ~borrow_observations:[ recall ] 3L) + |> ok + in + let encoded = List.map T.Codec.audit_to_string events in + Alcotest.(check bool) + "recall and financing audits serialize" true + (List.for_all (fun value -> String.length value > 0) encoded); + Alcotest.check quantity_testable "recalled short closed" + T.Scalar.Quantity.zero + (T.Account.position_quantity (Runner.account state) + (instrument_id "test-equity")); + Alcotest.(check bool) + "recall and observed charge audited" true + (List.exists + (fun event -> + match event.T.Audit.event with + | T.Audit.Borrow_recall_received _ -> true + | _ -> false) + events + && List.exists + (fun event -> + match event.T.Audit.event with + | T.Audit.Borrow_charge_applied _ -> true + | _ -> false) + events) + +let constructors_reject_ambiguous_observations () = + let effective_at = timestamp "2026-01-01T00:00:00Z" in + Alcotest.(check bool) + "borrow rate bounds enforced" true + (Result.is_error + (T.Financing.borrow_observation + ~instrument_id:(instrument_id "test-equity") + ~effective_at ~available_quantity:(quantity "1") + ~annual_rate_bps:(-1_000_001) ~recalled:false)); + Alcotest.(check bool) + "recall cannot retain availability" true + (Result.is_error + (T.Financing.borrow_observation + ~instrument_id:(instrument_id "test-equity") + ~effective_at ~available_quantity:(quantity "1") ~annual_rate_bps:100 + ~recalled:true)); + Alcotest.(check bool) + "rate bounds enforced" true + (Result.is_error + (T.Financing.cash_rate_observation ~currency:"USD" ~effective_at + ~credit_rate_bps:1_000_001 ~debit_rate_bps:0)); + Alcotest.(check bool) + "currency validation enforced" true + (Result.is_error + (T.Financing.cash_rate_observation ~currency:"" ~effective_at + ~credit_rate_bps:0 ~debit_rate_bps:0)) + +let tests = + [ + Alcotest.test_case "explicit accrual policies" `Quick + explicit_accrual_policies; + Alcotest.test_case "accrual boundaries and names" `Quick + accrual_boundaries_and_policy_names; + Alcotest.test_case "cash interest attribution" `Quick + cash_interest_is_ledger_attributed; + Alcotest.test_case "explicit missing data" `Quick + missing_data_policies_are_explicit; + Alcotest.test_case "locate order rejection" `Quick + reject_order_policy_uses_current_locate; + Alcotest.test_case "zero missing data and retained recall" `Quick + zero_missing_data_and_recall_retention; + Alcotest.test_case "availability and recall" `Quick + availability_clips_and_recall_closes; + Alcotest.test_case "observation validation" `Quick + constructors_reject_ambiguous_observations; + ] diff --git a/test/test_order_lifetimes.ml b/test/test_order_lifetimes.ml new file mode 100644 index 0000000..a464ff5 --- /dev/null +++ b/test/test_order_lifetimes.ml @@ -0,0 +1,657 @@ +open Test_support +module T = Trading_engine +module Runner = T.Engine.Make (T.Scripted_strategy) + +let event_names events = + List.map (fun event -> T.Audit.event_name event.T.Audit.event) events + +let runner ?(initial_cash = "10000") ?(risk = risk ()) ?(venue_calendars = []) + schedule = + let strategy_state = T.Scripted_strategy.create schedule |> ok in + Runner.create ~run_id:(run_id "lifetime-run") ~scenario_sha256 + ~config:(engine_config ~risk ~venue_calendars ()) + ~initial_portfolio: + (initial_portfolio ~cash:[ ("USD", money initial_cash) ] ()) + ~strategy_state + |> ok + +let calendar ?(venue = "XNAS") ?(covered_instrument = "test-equity") () = + let phase = + T.Venue_calendar.create_phase ~kind:T.Venue_calendar.Regular + ~opens_at:(timestamp "2026-01-03T14:30:00Z") + ~closes_at:(timestamp "2026-01-03T21:00:00Z") + |> ok + in + let session = + T.Venue_calendar.create_session ~session_date:"2026-01-03" + ~kind:T.Venue_calendar.Regular_session ~phases:[ phase ] + |> ok + in + T.Venue_calendar.create + ~id:(T.Id.Venue_calendar.of_string_exn "xnas-test") + ~version:"1" + ~venue_id:(T.Id.Venue.of_string_exn venue) + ~instrument_ids:[ instrument_id covered_instrument ] + ~sessions:[ session ] + |> ok + +let validates_stop_limit_and_gtd () = + Alcotest.(check bool) + "invalid buy stop-limit" true + (Result.is_error + (T.Order.request + ~instrument_id:(instrument_id "test-equity") + ~side:T.Order.Buy ~quantity:(quantity "1") + ~kind: + (T.Order.Stop_limit + { trigger_price = price "100"; limit_price = price "99" }) + ~time_in_force:T.Order.Gtc ~origin:T.Order.Direct)); + let request = + request ~time_in_force:(T.Order.Gtd (timestamp "2026-01-02T20:00:00Z")) () + in + Alcotest.(check bool) + "expiry follows creation" true + (Result.is_error + (T.Order.accept ~id:(order_id "expired") + ~created_event_id:(event_id "expired-event") ~accepted_sequence:1L + ~created_at:(timestamp "2026-01-02T21:00:00Z") + ~eligible_after_slice_sequence:1L request)) + +let intent_requires_explicit_companions () = + let intent = + `Assoc + [ + ("type", `String "submit_order"); + ("instrument_id", `String "test-equity"); + ("side", `String "buy"); + ("quantity", `String "1"); + ("order_kind", `String "stop"); + ("trigger_price", `String "110"); + ("limit_price", `Null); + ("time_in_force", `String "gtd"); + ("venue_id", `Null); + ("calendar_id", `Null); + ("expires_at", `String "2026-01-03T20:00:00Z"); + ] + in + Alcotest.(check bool) + "explicit stop/GTD parses" true + (Result.is_ok (T.Scenario.intent_of_yojson intent)); + let missing_trigger = + match intent with + | `Assoc fields -> `Assoc (List.remove_assoc "trigger_price" fields) + | _ -> assert false + in + Alcotest.(check bool) + "missing companion is rejected" true + (Result.is_error (T.Scenario.intent_of_yojson missing_trigger)); + let submit kind trigger limit tif venue calendar expires = + `Assoc + [ + ("type", `String "submit_order"); + ("instrument_id", `String "test-equity"); + ("side", `String "buy"); + ("quantity", `String "1"); + ("order_kind", `String kind); + ("trigger_price", trigger); + ("limit_price", limit); + ("time_in_force", `String tif); + ("venue_id", venue); + ("calendar_id", calendar); + ("expires_at", expires); + ] + in + let valid = + [ + submit "market" `Null `Null "ioc" `Null `Null `Null; + submit "limit" `Null (`String "100") "fok" `Null `Null `Null; + submit "stop_limit" (`String "100") (`String "101") "day" (`String "XNAS") + (`String "xnas-test") `Null; + submit "limit" `Null (`String "100") "gtc" `Null `Null `Null; + ] + in + List.iter + (fun json -> + Alcotest.(check bool) + "order variant parses" true + (Result.is_ok (T.Scenario.intent_of_yojson json))) + valid; + Alcotest.(check bool) + "inconsistent TIF companions rejected" true + (Result.is_error + (T.Scenario.intent_of_yojson + (submit "market" `Null `Null "gtc" (`String "XNAS") `Null `Null))) + +let order_validation_and_serialization_branches () = + Alcotest.(check bool) + "nonpositive quantity rejected" true + (Result.is_error + (T.Order.request + ~instrument_id:(instrument_id "test-equity") + ~side:T.Order.Buy ~quantity:T.Scalar.Quantity.zero + ~kind:T.Order.Market ~time_in_force:T.Order.Gtc ~origin:T.Order.Direct)); + Alcotest.(check bool) + "invalid sell stop-limit rejected" true + (Result.is_error + (T.Order.request + ~instrument_id:(instrument_id "test-equity") + ~side:T.Order.Sell ~quantity:(quantity "1") + ~kind: + (T.Order.Stop_limit + { trigger_price = price "100"; limit_price = price "101" }) + ~time_in_force:T.Order.Gtc ~origin:T.Order.Direct)); + let ordinary = current_request () in + Alcotest.(check bool) + "negative accepted sequence rejected" true + (Result.is_error + (T.Order.accept + ~id:(order_id "negative-sequence") + ~created_event_id:(event_id "negative-sequence-event") + ~accepted_sequence:(-1L) + ~created_at:(timestamp "2026-01-02T21:00:00Z") + ~eligible_after_slice_sequence:0L ordinary)); + Alcotest.(check bool) + "negative eligibility rejected" true + (Result.is_error + (T.Order.accept + ~id:(order_id "negative-eligibility") + ~created_event_id:(event_id "negative-eligibility-event") + ~accepted_sequence:1L + ~created_at:(timestamp "2026-01-02T21:00:00Z") + ~eligible_after_slice_sequence:(-1L) ordinary)); + Alcotest.(check bool) + "empty rejection reason rejected" true + (Result.is_error + (T.Order.reject + ~id:(order_id "empty-rejection") + ~created_event_id:(event_id "empty-rejection-event") + ~rejected_sequence:1L + ~created_at:(timestamp "2026-01-02T21:00:00Z") + ~eligible_after_slice_sequence:0L ordinary ~reason:"")); + let cases = + [ + (T.Order.Market, T.Order.Ioc); + (T.Order.Limit (price "100"), T.Order.Fok); + (T.Order.Stop (price "110"), T.Order.Gtc); + ( T.Order.Stop_limit + { trigger_price = price "110"; limit_price = price "111" }, + T.Order.Day + { + venue_id = T.Id.Venue.of_string_exn "XNAS"; + calendar_id = T.Id.Venue_calendar.of_string_exn "xnas-test"; + } ); + ( T.Order.Limit (price "100"), + T.Order.Gtd (timestamp "2026-01-03T20:00:00Z") ); + ] + in + List.iteri + (fun index (kind, time_in_force) -> + let request = current_request ~kind ~time_in_force () in + ignore (T.Order.kind_to_string kind); + ignore (T.Order.time_in_force_to_string time_in_force); + let order = + accepted_order ~id:(Printf.sprintf "serialized-%d" index) request + in + ignore (T.Order.is_market order); + ignore (T.Order.effective_kind order); + match T.Codec.order_to_yojson order with + | `Assoc fields -> + Alcotest.(check bool) + "TIF serialized" true + (List.mem_assoc "time_in_force" fields) + | _ -> Alcotest.fail "serialized order must be an object") + cases; + let unconditional = accepted_order (current_request ()) in + Alcotest.(check bool) + "unconditional order cannot trigger" true + (Result.is_error + (T.Order.trigger unconditional + ~updated_event_id:(event_id "invalid-trigger") + ~triggered_at:(timestamp "2026-01-03T20:00:00Z") + ~triggered_slice_sequence:2L)); + let conditional = + accepted_order (current_request ~kind:(T.Order.Stop (price "110")) ()) + in + Alcotest.(check bool) + "negative trigger sequence rejected" true + (Result.is_error + (T.Order.trigger conditional + ~updated_event_id:(event_id "invalid-sequence") + ~triggered_at:(timestamp "2026-01-03T20:00:00Z") + ~triggered_slice_sequence:(-1L))); + let triggered = + T.Order.trigger conditional ~updated_event_id:(event_id "valid-trigger") + ~triggered_at:(timestamp "2026-01-03T20:00:00Z") + ~triggered_slice_sequence:2L + |> ok + in + ignore (T.Codec.order_to_yojson triggered); + Alcotest.(check bool) + "duplicate trigger rejected" true + (Result.is_error + (T.Order.trigger triggered + ~updated_event_id:(event_id "duplicate-trigger") + ~triggered_at:(timestamp "2026-01-03T20:00:00Z") + ~triggered_slice_sequence:3L)); + let cancelled = T.Order.cancel conditional |> ok in + Alcotest.(check bool) + "cancelled order is terminal" true + (T.Order.is_terminal cancelled); + Alcotest.(check bool) + "terminal trigger rejected" true + (Result.is_error + (T.Order.trigger cancelled + ~updated_event_id:(event_id "terminal-trigger") + ~triggered_at:(timestamp "2026-01-03T20:00:00Z") + ~triggered_slice_sequence:2L)) + +let trigger_then_execute_on_following_slice () = + let request = + current_request + ~kind:(T.Order.Stop (price "110")) + ~time_in_force:T.Order.Gtc () + in + let oms, order = oms_with_order request in + let trigger_slice = + market_slice + ~bars:[ bar ~open_price:"100" ~high_price:"115" ~low_price:"95" 2L ] + 2L + in + let pure_match = + T.Execution.match_slice (execution ()) + ~instruments:[ instrument () ] + ~oms trigger_slice + |> ok + in + Alcotest.(check int) + "pure match reports one trigger" 1 + (List.length pure_match.triggers); + Alcotest.(check int) + "trigger slice has no fill" 0 + (List.length pure_match.fills); + let cursor = + T.Execution.start_slice (execution ()) + ~instruments:[ instrument () ] + ~oms trigger_slice + |> ok + in + let triggered_at, triggered_sequence = + match T.Execution.next cursor ~oms |> ok with + | T.Execution.Triggered (id, at, sequence, _) -> + Alcotest.check order_id_testable "triggered order" order.id id; + (at, sequence) + | T.Execution.Finished _ | T.Execution.Proposed _ -> + Alcotest.fail "expected a trigger" + in + Alcotest.(check string) + "intrabar trigger is timestamped at bar end" "2026-01-03T21:00:00.000000Z" + (T.Codec.ptime_to_string triggered_at); + Alcotest.(check int64) "trigger slice persisted" 2L triggered_sequence; + let oms, _ = + T.Oms.trigger oms order.id ~updated_event_id:(event_id "trigger-event") + ~triggered_at ~triggered_slice_sequence:triggered_sequence + |> ok + in + let next = + T.Execution.match_slice (execution ()) + ~instruments:[ instrument () ] + ~oms + (market_slice ~bars:[ bar ~open_price:"112" 3L ] 3L) + |> ok + in + match next.fills with + | [ fill ] -> + Alcotest.check price_testable "stop becomes next-slice market order" + (price "112") fill.price + | _ -> Alcotest.fail "expected one next-slice fill" + +let stop_limit_uses_limit_after_trigger () = + let request = + request + ~kind: + (T.Order.Stop_limit + { trigger_price = price "110"; limit_price = price "111" }) + () + in + let oms, order = oms_with_order request in + let oms, _ = + T.Oms.trigger oms order.id ~updated_event_id:(event_id "trigger-event") + ~triggered_at:(timestamp "2026-01-03T21:00:00Z") + ~triggered_slice_sequence:2L + |> ok + in + let missed = + T.Execution.match_slice (execution ()) + ~instruments:[ instrument () ] + ~oms + (market_slice + ~bars: + [ + bar ~open_price:"115" ~high_price:"118" ~low_price:"112" + ~close_price:"115" 3L; + ] + 3L) + |> ok + in + Alcotest.(check int) + "activated limit can remain working" 0 (List.length missed.fills); + let touched = + T.Execution.match_slice (execution ()) + ~instruments:[ instrument () ] + ~oms + (market_slice + ~bars: + [ + bar ~open_price:"115" ~high_price:"118" ~low_price:"110" + ~close_price:"115" 4L; + ] + 4L) + |> ok + in + Alcotest.check price_testable "activated limit fills at its limit" + (price "111") (List.hd touched.fills).price + +let sell_stop_gap_and_partial_fill () = + let request = + current_request ~side:T.Order.Sell ~quantity_value:"10" + ~kind:(T.Order.Stop (price "90")) + () + in + let oms, order = oms_with_order request in + let gap_slice = + market_slice + ~bars: + [ + bar ~open_price:"85" ~high_price:"90" ~low_price:"80" + ~close_price:"85" 2L; + ] + 2L + in + let matched = + T.Execution.match_slice (execution ()) + ~instruments:[ instrument () ] + ~oms gap_slice + |> ok + in + let triggered_at = + match matched.triggers with + | [ (id, triggered_at, 2L) ] -> + Alcotest.check order_id_testable "sell stop ID" order.id id; + triggered_at + | _ -> Alcotest.fail "expected one sell-stop trigger" + in + Alcotest.(check string) + "gap trigger uses bar start" "2026-01-03T14:30:00.000000Z" + (T.Codec.ptime_to_string triggered_at); + let oms, _ = + T.Oms.trigger oms order.id ~updated_event_id:(event_id "sell-trigger") + ~triggered_at ~triggered_slice_sequence:2L + |> ok + in + let partial = + T.Execution.match_slice (execution ()) + ~instruments:[ instrument () ] + ~oms + (market_slice ~bars:[ bar ~volume:(Some "4") 3L ] 3L) + |> ok + in + match partial.fills with + | [ fill ] -> + Alcotest.check quantity_testable "activated stop can partially fill" + (quantity "4") fill.quantity + | _ -> Alcotest.fail "expected one partial sell-stop fill" + +let fok_is_all_or_cancel () = + let request = + current_request ~quantity_value:"10" ~time_in_force:T.Order.Fok () + in + let oms, order = oms_with_order request in + let matched = + T.Execution.match_slice (execution ()) + ~instruments:[ instrument () ] + ~oms + (market_slice ~bars:[ bar ~volume:(Some "5") 2L ] 2L) + |> ok + in + Alcotest.(check int) "no partial FOK fill" 0 (List.length matched.fills); + Alcotest.check order_id_testable "FOK is cancelled" order.id + (List.hd matched.market_ioc_orders) + +let split_adjusts_stop_prices () = + let request = + current_request ~quantity_value:"10" + ~kind: + (T.Order.Stop_limit + { trigger_price = price "110"; limit_price = price "112" }) + () + in + let order = accepted_order request in + let adjusted = + T.Order.adjust_for_split order ~updated_event_id:(event_id "split-event") + ~numerator:2L ~denominator:1L + |> ok + in + Alcotest.check quantity_testable "quantity doubles" (quantity "20") + adjusted.request.quantity; + match adjusted.request.kind with + | T.Order.Stop_limit { trigger_price; limit_price } -> + Alcotest.check price_testable "trigger halves" (price "55") trigger_price; + Alcotest.check price_testable "limit halves" (price "56") limit_price + | T.Order.Market | T.Order.Limit _ | T.Order.Stop _ -> + Alcotest.fail "expected adjusted stop-limit" + +let engine_audits_trigger_and_defers_fill () = + let request = + current_request + ~kind:(T.Order.Stop (price "110")) + ~time_in_force:T.Order.Gtc () + in + let state = runner [ (1L, [ T.Strategy.Submit_order request ]) ] in + let state, _ = Runner.process_slice state (market_slice 1L) |> ok in + let state, triggered = + Runner.process_slice state + (market_slice + ~bars: + [ + bar ~open_price:"100" ~high_price:"115" ~low_price:"95" + ~close_price:"105" 2L; + ] + 2L) + |> ok + in + Alcotest.(check bool) + "trigger is audited" true + (List.mem "order_triggered" (event_names triggered)); + Alcotest.(check bool) + "trigger slice does not fill" false + (List.mem "fill_applied" (event_names triggered)); + let _, filled = + Runner.process_slice state + (market_slice ~bars:[ bar ~open_price:"112" 3L ] 3L) + |> ok + in + Alcotest.(check bool) + "following slice fills" true + (List.mem "fill_applied" (event_names filled)) + +let gtd_and_day_expire_deterministically () = + let gtd = + request + ~kind:(T.Order.Limit (price "90")) + ~time_in_force:(T.Order.Gtd (timestamp "2026-01-03T20:00:00Z")) + () + in + let state = runner [ (1L, [ T.Strategy.Submit_order gtd ]) ] in + let state, _ = Runner.process_slice state (market_slice 1L) |> ok in + let _, expired = Runner.process_slice state (market_slice 2L) |> ok in + let reason = + match + List.find_map + (fun audit -> + match audit.T.Audit.event with + | T.Audit.Order_cancelled { reason; _ } -> Some reason + | _ -> None) + expired + with + | Some reason -> reason + | None -> + Alcotest.failf "missing GTD cancellation in [%s]" + (String.concat ", " (event_names expired)) + in + Alcotest.(check string) + "GTD reason" "gtd_expired" + (T.Audit.cancellation_reason_to_string reason); + let calendar = calendar () in + let day = + request + ~kind:(T.Order.Stop (price "101")) + ~time_in_force: + (T.Order.Day + { + venue_id = T.Id.Venue.of_string_exn "XNAS"; + calendar_id = T.Id.Venue_calendar.of_string_exn "xnas-test"; + }) + () + in + let state = + runner ~venue_calendars:[ calendar ] + [ (1L, [ T.Strategy.Submit_order day ]) ] + in + let state, _ = Runner.process_slice state (market_slice 1L) |> ok in + let _, expired = + Runner.process_slice state + (market_slice + ~bars: + [ + bar ~open_price:"100" ~high_price:"105" ~low_price:"95" + ~close_price:"100" 2L; + ] + 2L) + |> ok + in + let reason = + List.find_map + (fun audit -> + match audit.T.Audit.event with + | T.Audit.Order_cancelled { reason; _ } -> Some reason + | _ -> None) + expired + |> Option.get + in + Alcotest.(check string) + "DAY reason" "day_expired" + (T.Audit.cancellation_reason_to_string reason); + Alcotest.(check bool) + "DAY stop triggers at session boundary" true + (List.mem "order_triggered" (event_names expired)); + Alcotest.(check bool) + "DAY stop cannot fill after its session" false + (List.mem "fill_applied" (event_names expired)) + +let fok_rejects_risk_clipped_fill () = + let order = current_request ~time_in_force:T.Order.Fok () in + let state = + runner ~initial_cash:"550" + ~risk:(risk ~max_leverage:"1" ()) + [ (1L, [ T.Strategy.Submit_order order ]) ] + in + let state, _ = + Runner.process_slice state + (market_slice ~bars:[ bar ~close_price:"50" ~low_price:"50" 1L ] 1L) + |> ok + in + let state, events = + Runner.process_slice state + (market_slice ~bars:[ bar ~open_price:"100" ~close_price:"100" 2L ] 2L) + |> ok + in + Alcotest.check quantity_testable "FOK applies no position" + T.Scalar.Quantity.zero + (T.Account.position_quantity (Runner.account state) + (instrument_id "test-equity")); + Alcotest.(check bool) + "risk clipping is audited" true + (List.mem "fill_clipped" (event_names events)); + let reason = + List.find_map + (fun audit -> + match audit.T.Audit.event with + | T.Audit.Order_cancelled { reason; _ } -> Some reason + | _ -> None) + events + |> Option.get + in + Alcotest.(check string) + "FOK cancellation reason" "fill_or_kill" + (T.Audit.cancellation_reason_to_string reason) + +let day_identity_is_validated () = + let day venue = + request + ~kind:(T.Order.Limit (price "90")) + ~time_in_force: + (T.Order.Day + { + venue_id = T.Id.Venue.of_string_exn venue; + calendar_id = T.Id.Venue_calendar.of_string_exn "xnas-test"; + }) + () + in + let rejection state = + let _, events = Runner.process_slice state (market_slice 1L) |> ok in + List.find_map + (fun audit -> + match audit.T.Audit.event with + | T.Audit.Order_rejected { status = T.Order.Rejected reason; _ } -> + Some reason + | _ -> None) + events + |> Option.get + in + let unknown = runner [ (1L, [ T.Strategy.Submit_order (day "XNAS") ]) ] in + Alcotest.(check string) + "unknown calendar" "DAY order refers to an unknown calendar" + (rejection unknown); + let wrong_venue = + runner + ~venue_calendars:[ calendar () ] + [ (1L, [ T.Strategy.Submit_order (day "XNYS") ]) ] + in + Alcotest.(check string) + "venue mismatch" "DAY order venue differs from its calendar" + (rejection wrong_venue); + let uncovered = + runner + ~venue_calendars:[ calendar ~covered_instrument:"other-equity" () ] + [ (1L, [ T.Strategy.Submit_order (day "XNAS") ]) ] + in + Alcotest.(check string) + "instrument coverage" "DAY order calendar does not cover its instrument" + (rejection uncovered) + +let tests = + [ + Alcotest.test_case "stop-limit and GTD validation" `Quick + validates_stop_limit_and_gtd; + Alcotest.test_case "intent companions" `Quick + intent_requires_explicit_companions; + Alcotest.test_case "order validation and serialization" `Quick + order_validation_and_serialization_branches; + Alcotest.test_case "stop triggers before later execution" `Quick + trigger_then_execute_on_following_slice; + Alcotest.test_case "stop-limit activation" `Quick + stop_limit_uses_limit_after_trigger; + Alcotest.test_case "sell stop gap and partial fill" `Quick + sell_stop_gap_and_partial_fill; + Alcotest.test_case "FOK all-or-cancel" `Quick fok_is_all_or_cancel; + Alcotest.test_case "split adjusts stop prices" `Quick + split_adjusts_stop_prices; + Alcotest.test_case "engine audits stop trigger" `Quick + engine_audits_trigger_and_defers_fill; + Alcotest.test_case "GTD and DAY expiration" `Quick + gtd_and_day_expire_deterministically; + Alcotest.test_case "FOK rejects risk clipping" `Quick + fok_rejects_risk_clipped_fill; + Alcotest.test_case "DAY identity validation" `Quick + day_identity_is_validated; + ] diff --git a/test/test_reducer.ml b/test/test_reducer.ml index e084c1c..06e9c1e 100644 --- a/test/test_reducer.ml +++ b/test/test_reducer.ml @@ -2,12 +2,15 @@ open Test_support module T = Trading_engine module Runner = T.Engine.Make (T.Scripted_strategy) -let runner ?(initial_cash = "10000") ?(risk = risk ()) ?execution_model - ?(execution = execution ()) schedule = +let runner ?contract_version ?(initial_cash = "10000") ?(risk = risk ()) + ?execution_model ?(execution = execution ()) schedule = let strategy_state = T.Scripted_strategy.create schedule |> ok in - let config = engine_config ~risk ?execution_model ~execution () in + let config = + engine_config ?contract_version ~risk ?execution_model ~execution () + in Runner.create ~run_id:(run_id "test-run") ~scenario_sha256 ~config - ~initial_cash:[ ("USD", money initial_cash) ] + ~initial_portfolio: + (initial_portfolio ~cash:[ ("USD", money initial_cash) ] ()) ~strategy_state |> ok @@ -47,6 +50,8 @@ let market_order_retries_after_partial_fill () = "first audit order" [ "run_started"; + "initial_state"; + "valuation"; "market_slice_received"; "target_portfolio_requested"; "order_accepted"; @@ -72,6 +77,7 @@ let market_order_retries_after_partial_fill () = [ "market_slice_received"; "fill_applied"; + "settlement_instruction_created"; "order_cancelled"; "order_accepted"; "valuation"; @@ -184,45 +190,6 @@ let superseding_target_replaces_retry () = (event_names events))) | _ -> Alcotest.fail "expected one replacement order" -let margin_limit_clips_buy_to_lots () = - let constrained = risk ~max_leverage:"1" () in - let state = - runner ~initial_cash:"550" ~risk:constrained - ~execution:(execution ~fixed_fee:"10" ()) - [ (1L, [ target "10" ]) ] - in - let decision_bar = - bar ~open_price:"50" ~high_price:"50" ~low_price:"50" ~close_price:"50" 1L - in - let state, _ = - Runner.process_slice state (market_slice ~bars:[ decision_bar ] 1L) |> ok - in - let state, events = - Runner.process_slice state - (market_slice ~bars:[ bar ~open_price:"100" ~close_price:"100" 2L ] 2L) - |> ok - in - Alcotest.check quantity_testable "five risk-permitted shares" (quantity "5") - (T.Account.position_quantity (Runner.account state) - (instrument_id "test-equity")); - Alcotest.check money_testable "cash after clipped fill" (money "40") - (account_cash (Runner.account state)); - let limited = - List.find - (fun audit -> - String.equal (T.Audit.event_name audit.T.Audit.event) "margin_limited") - events - in - match limited.event with - | T.Audit.Margin_limited - { requested_quantity; permitted_quantity; price = fill_price; _ } -> - Alcotest.check quantity_testable "ten requested" (quantity "10") - requested_quantity; - Alcotest.check quantity_testable "five permitted" (quantity "5") - permitted_quantity; - Alcotest.check price_testable "actual price" (price "100") fill_price - | _ -> Alcotest.fail "expected margin limit audit" - let sells_precede_buys_in_the_same_slice () = let a = instrument ~id:"asset-a" ~symbol:"A" () in let b = instrument ~id:"asset-b" ~symbol:"B" () in @@ -242,6 +209,7 @@ let sells_precede_buys_in_the_same_slice () = in let state = runner ~initial_cash:"1000" ~risk:configured + ~execution:(execution ~instruments:[ a; b ] ()) [ (1L, [ portfolio "10" "0" ]); (2L, [ portfolio "0" "10" ]) ] in let state, _ = @@ -280,7 +248,7 @@ let interactive_market_slice_timeline_is_non_overlapping () = let initial = T.Engine.Interactive.create ~run_id:(run_id "timeline-test") ~scenario_sha256 ~config - ~initial_cash:[ ("USD", money "10000") ] + ~initial_portfolio:(initial_portfolio ~cash:[ ("USD", money "10000") ] ()) |> ok in let rec finish progress = @@ -331,7 +299,7 @@ let internal_feedback_is_capped () = let config = engine_config ~max_internal_events:3 () in let state = Looping_runner.create ~run_id:(run_id "loop") ~scenario_sha256 ~config - ~initial_cash:[ ("USD", money "10000") ] + ~initial_portfolio:(initial_portfolio ~cash:[ ("USD", money "10000") ] ()) ~strategy_state |> ok in @@ -344,7 +312,7 @@ let exact_internal_event_limit_succeeds () = let config = engine_config ~max_internal_events:1 () in let state = Runner.create ~run_id:(run_id "one-event") ~scenario_sha256 ~config - ~initial_cash:[ ("USD", money "10000") ] + ~initial_portfolio:(initial_portfolio ~cash:[ ("USD", money "10000") ] ()) ~strategy_state |> ok in @@ -352,12 +320,42 @@ let exact_internal_event_limit_succeeds () = "one callback fits a limit of one" true (Result.is_ok (Runner.process_slice state (market_slice 1L))) +let reducer_feedback_queue_handles_large_batches () = + let batch_size = T.Resource_limits.intents_per_batch in + let invalid_intent = + T.Strategy.Submit_order + (request ~quantity_value:"1" ~origin:T.Order.Target_rebalance ()) + in + let intents = List.init batch_size (fun _ -> invalid_intent) in + let strategy_state = T.Scripted_strategy.create [ (1L, intents) ] |> ok in + let max_internal_events = (2 * batch_size) + 1 in + let config = engine_config ~max_internal_events () in + let state = + Runner.create ~run_id:(run_id "large-feedback") ~scenario_sha256 ~config + ~initial_portfolio:(initial_portfolio ~cash:[ ("USD", money "10000") ] ()) + ~strategy_state + |> ok + in + let _, events = Runner.process_slice state (market_slice 1L) |> ok in + let rejection_count = + List.fold_left + (fun count audit -> + match audit.T.Audit.event with + | T.Audit.Intent_rejected _ -> count + 1 + | _ -> count) + 0 events + in + Alcotest.(check int) "every intent rejected" batch_size rejection_count; + Alcotest.(check int) + "batch completes at the exact feedback limit" (batch_size + 5) + (List.length events) + let completed_run_is_terminal_and_hash_bound () = let state = runner [] in let state, valuation, events = Runner.complete state |> ok in Alcotest.(check (list string)) "start and completion events" - [ "run_started"; "run_completed" ] + [ "run_started"; "initial_state"; "valuation"; "run_completed" ] (event_names events); Alcotest.check money_testable "initial equity" (money "10000") valuation.equity; @@ -377,18 +375,12 @@ let completed_run_is_terminal_and_hash_bound () = let invalid_initial_state_is_rejected () = let strategy_state = T.Scripted_strategy.create [] |> ok in let config = engine_config () in - Alcotest.(check bool) - "negative cash rejected" true - (Result.is_error - (Runner.create ~run_id:(run_id "bad-cash") ~scenario_sha256 ~config - ~initial_cash:[ ("USD", money "-1") ] - ~strategy_state)); Alcotest.(check bool) "noncanonical hash rejected" true (Result.is_error (Runner.create ~run_id:(run_id "bad-hash") ~scenario_sha256:(String.make 64 'A') ~config - ~initial_cash:[ ("USD", money "1") ] + ~initial_portfolio:(initial_portfolio ~cash:[ ("USD", money "1") ] ()) ~strategy_state)) let one_valuation_per_slice () = @@ -401,7 +393,7 @@ let one_valuation_per_slice () = (fun name -> String.equal name "valuation") (event_names events)) in - Alcotest.(check int) "first slice" 1 (count first); + Alcotest.(check int) "initial state and first slice" 2 (count first); Alcotest.(check int) "second slice" 1 (count second) module No_fill_execution = struct @@ -437,6 +429,30 @@ let configured_execution_model_is_dispatched () = "selected model is audited" No_fill_execution.name actual | _ -> Alcotest.fail "expected run start" +let execution_model_configuration_must_match () = + let completed = T.Execution_model.find "completed_bar_v1" |> ok in + let next_open = T.Execution_model.find "completed_bar_next_open_v1" |> ok in + let conservative = + T.Execution.create_conservative ~participation_bps:10_000 ~fee_schedules:[] + ~half_spread_bps:0 ~impact_coefficient_bps:0 + ~missing_volume_policy:T.Execution.Reject_missing_volume + |> ok + in + let configure contract_version execution_model execution = + T.Engine.config ~contract_version ~risk:(risk ()) ~venue_calendars:[] + ~execution_model ~execution ~financing:(financing_policy ()) + ~settlement:(settlement_policy ()) ~max_internal_events:1000 + in + Alcotest.(check bool) + "conservative model requires pricing configuration" true + (Result.is_error (configure "1" next_open (execution ()))); + Alcotest.(check bool) + "completed-bar model rejects conservative pricing" true + (Result.is_error (configure "1" completed conservative)); + Alcotest.(check bool) + "matching conservative configuration accepted" true + (Result.is_ok (configure "1" next_open conservative)) + module Cancel_next_strategy = struct type state = { submitted : bool; cancelled : bool } @@ -467,8 +483,12 @@ module Cancel_next_runner = T.Engine.Make (Cancel_next_strategy) let callbacks_use_current_slice_and_apply_responses_before_matching () = let configured = instrument ~currency:"EUR" () in let configured_risk = risk ~instruments:[ configured ] () in - let config = engine_config ~risk:configured_risk () in - let initial_cash = [ ("USD", money "10000"); ("EUR", money "0") ] in + let config = + engine_config ~risk:configured_risk + ~execution:(execution ~instruments:[ configured ] ()) + () + in + let initial_cash = [ ("USD", money "0"); ("EUR", money "10000") ] in let first_slice = market_slice ~bars:[ bar ~close_price:"104" 1L ] @@ -491,7 +511,9 @@ let callbacks_use_current_slice_and_apply_responses_before_matching () = let scripted = Cancel_next_runner.create ~run_id:(run_id "callback-consistency") - ~scenario_sha256 ~config ~initial_cash ~strategy_state + ~scenario_sha256 ~config + ~initial_portfolio:(initial_portfolio ~cash:initial_cash ()) + ~strategy_state |> ok in let scripted, _ = @@ -503,7 +525,8 @@ let callbacks_use_current_slice_and_apply_responses_before_matching () = let interactive = T.Engine.Interactive.create ~run_id:(run_id "callback-consistency") - ~scenario_sha256 ~config ~initial_cash + ~scenario_sha256 ~config + ~initial_portfolio:(initial_portfolio ~cash:initial_cash ()) |> ok in let rec finish_first submitted progress = @@ -621,7 +644,7 @@ let interactive_reducer_matches_scripted_strategy () = let interactive = T.Engine.Interactive.create ~run_id:(run_id "test-run") ~scenario_sha256 ~config - ~initial_cash:[ ("USD", money "10000") ] + ~initial_portfolio:(initial_portfolio ~cash:[ ("USD", money "10000") ] ()) |> ok in let progress = @@ -662,6 +685,39 @@ let interactive_reducer_matches_scripted_strategy () = "completed slice cannot accept strategy intents" (T.Engine.Interactive.resume completed_progress [] |> error) +let explicit_phase_order_is_stable () = + let dividend = + T.Corporate_action.cash_dividend + ~id:(T.Id.Corporate_action.of_string_exn "phase-dividend") + ~instrument_id:(instrument_id "test-equity") + ~amount_per_unit:(money "1") + |> ok + in + let metric = + T.Metric.create ~name:"phase.boundary" ~value:(T.Metric.String "reached") () + |> ok + |> fun metric -> T.Strategy.Emit_metric metric + in + let state = runner [ (1L, [ target "2" ]); (2L, [ metric; target "0" ]) ] in + let state, _ = Runner.process_slice state (market_slice 1L) |> ok in + let _, events = + Runner.process_slice state (market_slice ~corporate_actions:[ dividend ] 2L) + |> ok + in + Alcotest.(check (list string)) + "actions, matching, notifications, targets, and valuation stay ordered" + [ + "market_slice_received"; + "cash_dividend_applied"; + "fill_applied"; + "settlement_instruction_created"; + "metric_emitted"; + "target_portfolio_requested"; + "order_accepted"; + "valuation"; + ] + (event_names events) + let tests = [ Alcotest.test_case "market target retries after partial fill" `Quick @@ -673,8 +729,6 @@ let tests = bounded_target_orders_make_progress; Alcotest.test_case "superseding target replaces retry" `Quick superseding_target_replaces_retry; - Alcotest.test_case "margin limit clips buys" `Quick - margin_limit_clips_buy_to_lots; Alcotest.test_case "same-slice sells precede buys" `Quick sells_precede_buys_in_the_same_slice; Alcotest.test_case "external ordering validation" `Quick @@ -685,6 +739,8 @@ let tests = internal_feedback_is_capped; Alcotest.test_case "exact internal event limit" `Quick exact_internal_event_limit_succeeds; + Alcotest.test_case "large reducer feedback batch" `Slow + reducer_feedback_queue_handles_large_batches; Alcotest.test_case "completed run is terminal and hash-bound" `Quick completed_run_is_terminal_and_hash_bound; Alcotest.test_case "invalid initial state rejected" `Quick @@ -692,8 +748,12 @@ let tests = Alcotest.test_case "one valuation per slice" `Quick one_valuation_per_slice; Alcotest.test_case "configured execution model is dispatched" `Quick configured_execution_model_is_dispatched; + Alcotest.test_case "execution model configuration matches" `Quick + execution_model_configuration_must_match; Alcotest.test_case "callbacks use current slice and synchronous responses" `Quick callbacks_use_current_slice_and_apply_responses_before_matching; Alcotest.test_case "interactive reducer matches scripted strategy" `Quick interactive_reducer_matches_scripted_strategy; + Alcotest.test_case "explicit phase order is stable" `Quick + explicit_phase_order_is_stable; ] diff --git a/test/test_reducer_properties.ml b/test/test_reducer_properties.ml new file mode 100644 index 0000000..42edfd0 --- /dev/null +++ b/test/test_reducer_properties.ml @@ -0,0 +1,944 @@ +open Test_support +module T = Trading_engine + +let ( let* ) result function_ = + match result with Ok value -> function_ value | Error _ as error -> error + +type asset = Primary | Foreign + +type command = + | Submit of { + asset : asset; + side : T.Order.side; + quantity : int; + limit : int option; + } + | Cancel_working of asset + | Target_quantities of { primary : int; foreign : int } + | Target_weights of { primary_bps : int; foreign_bps : int } + | Emit_metric of int + +type dividend = { asset : asset; cents : int } + +type step = { + primary_price : int; + foreign_price : int; + euro_rate_bps : int; + primary_volume : int; + foreign_volume : int; + dividend : dividend option; + commands : command list; +} + +type trace = { + leverage_tenths : int; + initial_margin_bps : int; + maintenance_margin_bps : int; + participation_bps : int; + split_asset : asset option; + steps : step list; +} + +let primary = instrument ~id:"property-primary" ~symbol:"PRIMARY" () + +let foreign = + instrument ~id:"property-foreign" ~symbol:"FOREIGN" ~currency:"EUR" () + +let instrument_for_asset = function Primary -> primary | Foreign -> foreign +let asset_name = function Primary -> "primary" | Foreign -> "foreign" +let side_name = function T.Order.Buy -> "buy" | T.Order.Sell -> "sell" + +let decimal_of_scaled value scale = + let sign = if value < 0 then "-" else "" in + let magnitude = abs value in + let whole = magnitude / scale in + let remainder = magnitude mod scale in + let digits = String.length (string_of_int (scale - 1)) in + let fraction = Printf.sprintf "%0*d" digits remainder in + let rec trim index = + if index < 0 then "" + else if Char.equal fraction.[index] '0' then trim (index - 1) + else String.sub fraction 0 (index + 1) + in + match trim (String.length fraction - 1) with + | "" -> Printf.sprintf "%s%d" sign whole + | fraction -> Printf.sprintf "%s%d.%s" sign whole fraction + +let json_of_asset asset = `String (asset_name asset) + +let json_of_command = function + | Submit { asset; side; quantity; limit } -> + `Assoc + [ + ("kind", `String "submit_order"); + ("asset", json_of_asset asset); + ("side", `String (side_name side)); + ("quantity", `Int quantity); + ( "limit", + Option.fold ~none:`Null ~some:(fun value -> `Int value) limit ); + ] + | Cancel_working asset -> + `Assoc + [ ("kind", `String "cancel_working"); ("asset", json_of_asset asset) ] + | Target_quantities { primary; foreign } -> + `Assoc + [ + ("kind", `String "target_quantities"); + ("primary", `Int primary); + ("foreign", `Int foreign); + ] + | Target_weights { primary_bps; foreign_bps } -> + `Assoc + [ + ("kind", `String "target_weights"); + ("primary_bps", `Int primary_bps); + ("foreign_bps", `Int foreign_bps); + ] + | Emit_metric value -> + `Assoc [ ("kind", `String "emit_metric"); ("value", `Int value) ] + +let json_of_step sequence step = + let dividend = + Option.fold step.dividend ~none:`Null ~some:(fun dividend -> + `Assoc + [ + ("asset", json_of_asset dividend.asset); + ("cents", `Int dividend.cents); + ]) + in + `Assoc + [ + ("slice_sequence", `Int sequence); + ("primary_price", `Int step.primary_price); + ("foreign_price", `Int step.foreign_price); + ("euro_rate_bps", `Int step.euro_rate_bps); + ("primary_volume", `Int step.primary_volume); + ("foreign_volume", `Int step.foreign_volume); + ("dividend", dividend); + ("commands", `List (List.map json_of_command step.commands)); + ] + +let print_trace trace = + let steps = List.mapi (fun index -> json_of_step (index + 1)) trace.steps in + `Assoc + [ + ("contract_version", `String T.Contract.version); + ("leverage_tenths", `Int trace.leverage_tenths); + ("initial_margin_bps", `Int trace.initial_margin_bps); + ("maintenance_margin_bps", `Int trace.maintenance_margin_bps); + ("participation_bps", `Int trace.participation_bps); + ( "split_on_slice_four", + Option.fold trace.split_asset ~none:`Null ~some:json_of_asset ); + ("steps", `List steps); + ] + |> Yojson.Safe.pretty_to_string + +let gen_asset = + QCheck2.Gen.map + (fun foreign -> if foreign then Foreign else Primary) + QCheck2.Gen.bool + +let gen_side = + QCheck2.Gen.map + (fun sell -> if sell then T.Order.Sell else T.Order.Buy) + QCheck2.Gen.bool + +let gen_command = + let open QCheck2.Gen in + oneof_weighted + [ + ( 7, + map + (fun (asset, side, quantity, limit) -> + Submit { asset; side; quantity; limit }) + (quad gen_asset gen_side (int_range 1 50) + (option ~ratio:0.45 (int_range 10 240))) ); + (2, map (fun asset -> Cancel_working asset) gen_asset); + ( 4, + map + (fun (primary, foreign) -> Target_quantities { primary; foreign }) + (pair (int_range (-100) 100) (int_range (-100) 100)) ); + ( 2, + map + (fun (primary_bps, foreign_bps) -> + Target_weights { primary_bps; foreign_bps }) + (pair (int_range (-12_500) 12_500) (int_range (-12_500) 12_500)) ); + (1, map (fun value -> Emit_metric value) (int_range (-1000) 1000)); + ] + +let gen_dividend = + let open QCheck2.Gen in + option ~ratio:0.25 + (map + (fun (asset, cents) -> { asset; cents }) + (pair gen_asset (int_range 1 250))) + +let gen_step = + let open QCheck2.Gen in + map + (fun ( (primary_price, foreign_price, euro_rate_bps, primary_volume), + (foreign_volume, dividend, commands) ) -> + { + primary_price; + foreign_price; + euro_rate_bps; + primary_volume; + foreign_volume; + dividend; + commands; + }) + (pair + (quad (int_range 20 220) (int_range 20 220) (int_range 4_000 20_000) + (int_range 1 80)) + (triple (int_range 1 80) gen_dividend + (list_size (int_range 0 3) gen_command))) + +let gen_trace = + let open QCheck2.Gen in + bind (int_range 2_500 10_000) (fun initial_margin_bps -> + map + (fun ( ( leverage_tenths, + maintenance_margin_bps, + participation_bps, + split_asset ), + steps ) -> + { + leverage_tenths; + initial_margin_bps; + maintenance_margin_bps; + participation_bps; + split_asset; + steps; + }) + (pair + (quad (int_range 10 30) + (int_range 1_000 initial_margin_bps) + (int_range 2_500 10_000) + (option ~ratio:0.5 gen_asset)) + (list_size (int_range 4 14) gen_step))) + +let make_risk trace = + risk ~instruments:[ primary; foreign ] ~max_order:"50" ~max_long:"100" + ~max_short:"100" ~max_gross:"50000" + ~max_leverage:(decimal_of_scaled trace.leverage_tenths 10) + ~initial_margin_bps:trace.initial_margin_bps + ~maintenance_margin_bps:trace.maintenance_margin_bps () + +let make_config trace risk = + engine_config ~risk + ~execution: + (execution ~participation_bps:trace.participation_bps + ~instruments:[ primary; foreign ] ()) + ~max_internal_events:5000 () + +let initial_cash = [ ("USD", money "10000"); ("EUR", money "5000") ] + +let permitted_submit_quantity context asset side requested = + let instrument_id = (instrument_for_asset asset).T.Instrument.id in + let has_working_order = + T.Strategy.working_orders context + |> List.exists (fun order -> + T.Id.Instrument.equal order.T.Order.request.instrument_id instrument_id) + in + if has_working_order then None + else + let current = T.Strategy.position context instrument_id in + let current_micros = T.Scalar.Quantity.to_micros current in + let crosses_current = + (Int64.compare current_micros 0L > 0 && side = T.Order.Sell) + || (Int64.compare current_micros 0L < 0 && side = T.Order.Buy) + in + let permitted = + if not crosses_current then requested + else + min requested + (Int64.div (Int64.abs current_micros) T.Scalar.Quantity.scale + |> Int64.to_int) + in + if permitted = 0 then None else Some permitted + +let command_intents context command = + match command with + | Submit { asset; side; quantity = quantity_value; limit } -> + permitted_submit_quantity context asset side quantity_value + |> Option.fold ~none:[] ~some:(fun quantity_value -> + let instrument = (instrument_for_asset asset).T.Instrument.id in + let kind = + Option.fold limit ~none:T.Order.Market ~some:(fun value -> + T.Order.Limit (price (string_of_int value))) + in + let request = + request ~instrument ~side + ~quantity_value:(string_of_int quantity_value) + ~kind () + in + [ T.Strategy.Submit_order request ]) + | Cancel_working asset -> + let instrument_id = (instrument_for_asset asset).id in + T.Strategy.working_orders context + |> List.find_opt (fun order -> + T.Id.Instrument.equal order.T.Order.request.instrument_id + instrument_id) + |> Option.fold ~none:[] ~some:(fun order -> + [ T.Strategy.Cancel_order order.T.Order.id ]) + | Target_quantities { primary = primary_value; foreign = foreign_value } -> + [ + T.Strategy.Target_quantities + [ + T.Strategy. + { + instrument_id = primary.id; + quantity = quantity (string_of_int primary_value); + }; + T.Strategy. + { + instrument_id = foreign.id; + quantity = quantity (string_of_int foreign_value); + }; + ]; + ] + | Target_weights { primary_bps; foreign_bps } -> + [ + T.Strategy.Target_weights + [ + T.Strategy. + { + instrument_id = primary.id; + weight = weight (decimal_of_scaled primary_bps 10_000); + }; + T.Strategy. + { + instrument_id = foreign.id; + weight = weight (decimal_of_scaled foreign_bps 10_000); + }; + ]; + ] + | Emit_metric value -> + [ + ( T.Metric.create ~name:"generated.reducer.metric" + ~value:(T.Metric.String (string_of_int value)) + () + |> Result.get_ok + |> fun metric -> T.Strategy.Emit_metric metric ); + ] + +module Asset_set = Set.Make (struct + type t = asset + + let compare = Stdlib.compare +end) + +let commands_to_intents context commands = + List.fold_left + (fun (intents, submitted_assets) command -> + match command with + | Submit { asset; _ } when Asset_set.mem asset submitted_assets -> + (intents, submitted_assets) + | Submit { asset; _ } -> + ( intents @ command_intents context command, + Asset_set.add asset submitted_assets ) + | _ -> (intents @ command_intents context command, submitted_assets)) + ([], Asset_set.empty) commands + |> fst + +module Generated_strategy = struct + type state = (int64 * command list) list + + let name = "generated-script" + + let take sequence schedule = + let rec loop reversed = function + | [] -> ([], List.rev reversed) + | (candidate, commands) :: rest when Int64.equal sequence candidate -> + (commands, List.rev_append reversed rest) + | item :: rest -> loop (item :: reversed) rest + in + loop [] schedule + + let on_event state context = function + | T.Strategy.Market_slice_closed slice -> + let commands, state = take slice.T.Market_slice.slice_sequence state in + (state, commands_to_intents context commands) + | T.Strategy.Fill_received _ | T.Strategy.Order_updated _ + | T.Strategy.Intent_rejected _ -> + (state, []) +end + +module Generated_runner = T.Engine.Make (Generated_strategy) + +let schedule trace = + List.mapi + (fun index step -> (Int64.of_int (index + 1), step.commands)) + trace.steps + +let corporate_actions trace index step = + let dividend = + Option.to_list step.dividend + |> List.map (fun dividend -> + let instrument = instrument_for_asset dividend.asset in + T.Corporate_action.cash_dividend + ~id: + (T.Id.Corporate_action.of_string_exn + (Printf.sprintf "property-dividend-%d" index)) + ~instrument_id:instrument.id + ~amount_per_unit:(money (decimal_of_scaled dividend.cents 100)) + |> ok) + in + match (index, trace.split_asset) with + | 4, Some asset -> + let instrument = instrument_for_asset asset in + T.Corporate_action.split + ~id:(T.Id.Corporate_action.of_string_exn "property-split") + ~instrument_id:instrument.id ~numerator:2L ~denominator:1L + |> ok + |> fun split -> split :: dividend + | _ -> dividend + +let make_bar instrument close_value volume_value = + let low_value = max 1 (close_value - 3) in + T.Bar.create ~instrument_id:instrument.T.Instrument.id + ~open_price:(price (string_of_int close_value)) + ~high_price:(price (string_of_int (close_value + 3))) + ~low_price:(price (string_of_int low_value)) + ~close_price:(price (string_of_int close_value)) + ~volume:(Some (quantity (string_of_int volume_value))) + |> ok + +let make_slice trace index step = + market_slice + ~bars: + [ + make_bar primary step.primary_price step.primary_volume; + make_bar foreign step.foreign_price step.foreign_volume; + ] + ~fx_rates: + [ + fx_mark (); + fx_mark ~currency:"EUR" + ~rate:(decimal_of_scaled step.euro_rate_bps 10_000) + (); + ] + ~corporate_actions:(corporate_actions trace index step) + (Int64.of_int index) + +let ensure condition message = if condition then Ok () else Error message + +let money_equal label expected actual = + ensure + (T.Scalar.Money.equal expected actual) + (Format.asprintf "%s: expected %a, got %a" label T.Scalar.Money.pp expected + T.Scalar.Money.pp actual) + +let sum_money values = + List.fold_left + (fun result value -> + let* total = result in + T.Scalar.Money.add total value) + (Ok T.Scalar.Money.zero) values + +let check_position_attribution position = + let* market_value = + T.Scalar.Money.notional position.T.Account.mark position.quantity + in + let* () = + money_equal "position market value" market_value position.market_value + in + let* unrealized = + T.Scalar.Money.subtract position.market_value position.cost_basis + in + let* () = + money_equal "position unrealized P&L" unrealized position.unrealized_pnl + in + let* total_fees = + T.Scalar.Money.add position.execution_fees position.borrow_fees + in + let* () = money_equal "position total fees" total_fees position.total_fees in + let* base_market = + T.Scalar.Money.convert position.market_value ~rate:position.fx_rate + in + let* () = + money_equal "position base market value" base_market + position.base_market_value + in + let* base_basis = + T.Scalar.Money.convert position.cost_basis ~rate:position.fx_rate + in + let* () = + money_equal "position base cost basis" base_basis position.base_cost_basis + in + let* base_realized = + T.Scalar.Money.convert position.realized_pnl ~rate:position.fx_rate + in + let* () = + money_equal "position base realized P&L" base_realized + position.base_realized_pnl + in + let* base_unrealized = + T.Scalar.Money.convert position.unrealized_pnl ~rate:position.fx_rate + in + let* () = + money_equal "position base unrealized P&L" base_unrealized + position.base_unrealized_pnl + in + let* base_total_fees = + T.Scalar.Money.convert position.total_fees ~rate:position.fx_rate + in + let* () = + money_equal "position base total fees" base_total_fees + position.base_total_fees + in + let* () = + ensure + (T.Scalar.Money.compare position.execution_fees T.Scalar.Money.zero >= 0) + "execution fees became negative" + in + ensure + (T.Scalar.Money.compare position.borrow_fees T.Scalar.Money.zero >= 0) + "borrow fees became negative" + +let check_valuation risk (valuation : T.Audit.valuation) = + let account = valuation.account in + let* () = + List.fold_left + (fun result position -> + let* () = result in + check_position_attribution position) + (Ok ()) account.positions + in + let* cash = + account.cash_balances + |> List.map (fun balance -> balance.T.Account.base_value) + |> sum_money + in + let* () = money_equal "cash attribution" cash account.cash in + let sum_position field = account.positions |> List.map field |> sum_money in + let* net = + sum_position (fun position -> position.T.Account.base_market_value) + in + let* () = money_equal "net market value" net account.net_market_value in + let* basis = + sum_position (fun position -> position.T.Account.base_cost_basis) + in + let* () = money_equal "cost basis" basis account.cost_basis in + let* realized = + sum_position (fun position -> position.T.Account.base_realized_pnl) + in + let* () = money_equal "realized P&L" realized account.realized_pnl in + let* unrealized = + sum_position (fun position -> position.T.Account.base_unrealized_pnl) + in + let* () = money_equal "unrealized P&L" unrealized account.unrealized_pnl in + let* execution_fees = + sum_position (fun position -> position.T.Account.base_execution_fees) + in + let* () = + money_equal "execution fee attribution" execution_fees + account.execution_fees + in + let* borrow_fees = + sum_position (fun position -> position.T.Account.base_borrow_fees) + in + let* () = + money_equal "borrow fee attribution" borrow_fees account.borrow_fees + in + let* total_fees = + sum_position (fun position -> position.T.Account.base_total_fees) + in + let* () = money_equal "total fee attribution" total_fees account.total_fees in + let* gross = + T.Scalar.Money.add account.long_market_value account.short_market_value + in + let* () = money_equal "gross exposure" gross account.gross_exposure in + let* negative_short = T.Scalar.Money.negate account.short_market_value in + let* signed_exposure = + T.Scalar.Money.add account.long_market_value negative_short + in + let* () = + money_equal "signed exposure" signed_exposure account.net_market_value + in + let* equity = T.Scalar.Money.add account.cash account.net_market_value in + let* () = money_equal "equity" equity account.equity in + let* expected_margin = T.Risk.margin_snapshot risk account in + let actual_margin = valuation.margin in + let* () = + money_equal "initial margin requirement" expected_margin.initial_requirement + actual_margin.initial_requirement + in + let* () = + money_equal "maintenance margin requirement" + expected_margin.maintenance_requirement + actual_margin.maintenance_requirement + in + let* () = + money_equal "initial margin excess" expected_margin.initial_excess + actual_margin.initial_excess + in + let* () = + money_equal "maintenance margin excess" expected_margin.maintenance_excess + actual_margin.maintenance_excess + in + ensure + (Bool.equal expected_margin.margin_call actual_margin.margin_call) + "margin call state disagrees with the account valuation" + +type audit_history = { next_sequence : int64; seen : T.Id.Event.Set.t } + +let empty_history = { next_sequence = 1L; seen = T.Id.Event.Set.empty } + +let check_audits history audits = + List.fold_left + (fun result audit -> + let* history = result in + let* () = + ensure + (Int64.equal audit.T.Audit.engine_sequence history.next_sequence) + "audit sequence is not contiguous" + in + let expected_id = + T.Audit.event_id ~run_id:audit.run_id + ~engine_sequence:audit.engine_sequence + in + let* () = + ensure + (T.Id.Event.equal expected_id audit.event_id) + "audit event ID is not derived from its sequence" + in + let canonical = List.sort_uniq T.Id.Event.compare audit.causation_ids in + let* () = + ensure + (List.equal T.Id.Event.equal canonical audit.causation_ids) + "audit causation IDs are not canonical" + in + let* () = + ensure + (List.for_all + (fun cause -> T.Id.Event.Set.mem cause history.seen) + audit.causation_ids) + "audit causation refers to a non-prior event" + in + Ok + { + next_sequence = Int64.succ history.next_sequence; + seen = T.Id.Event.Set.add audit.event_id history.seen; + }) + (Ok history) audits + +let check_order history order = + let request_quantity = order.T.Order.request.quantity in + let* () = + ensure + (T.Scalar.Quantity.is_positive request_quantity) + "order request quantity is not positive" + in + let* () = + ensure + (T.Scalar.Quantity.is_nonnegative order.filled_quantity) + "order filled quantity is negative" + in + let* () = + ensure + (T.Scalar.Quantity.compare order.filled_quantity request_quantity <= 0) + "order filled quantity exceeds its request" + in + let* () = + ensure + (T.Id.Event.Set.mem order.created_event_id history.seen) + "order creation event is absent from the audit history" + in + let* () = + ensure + (T.Id.Event.Set.mem order.updated_event_id history.seen) + "order update event is absent from the audit history" + in + match order.status with + | T.Order.Working -> + ensure + (T.Scalar.Quantity.compare order.filled_quantity request_quantity < 0) + "working order is completely filled" + | T.Order.Partially_filled -> + ensure + (T.Scalar.Quantity.is_positive order.filled_quantity + && T.Scalar.Quantity.compare order.filled_quantity request_quantity < 0 + ) + "partial order has an invalid fill quantity" + | T.Order.Filled -> + ensure + (T.Scalar.Quantity.equal order.filled_quantity request_quantity) + "filled order has a remainder" + | T.Order.Cancelled | T.Order.Rejected _ -> Ok () + +let check_orders history oms = + let orders = T.Oms.orders oms in + let* () = + List.fold_left + (fun result order -> + let* () = result in + check_order history order) + (Ok ()) orders + in + let expected_active = List.filter T.Order.is_active orders in + ensure + (List.length expected_active = List.length (T.Oms.active_orders oms)) + "active order inventory is inconsistent" + +let slice_valuation audits = + List.filter_map + (fun audit -> + match audit.T.Audit.event with + | T.Audit.Valuation value -> Some value + | _ -> None) + audits + |> List.rev + |> function + | valuation :: _ -> Ok valuation + | [] -> Error "slice did not emit a valuation" + +let check_fill oms audit = + match audit.T.Audit.event with + | T.Audit.Fill_applied fill -> + let* expected_notional = + T.Scalar.Money.notional fill.T.Fill.price fill.quantity + in + let* () = money_equal "fill notional" expected_notional fill.notional in + let* () = + ensure + (T.Scalar.Quantity.is_positive fill.quantity) + "fill quantity is not positive" + in + let* () = + ensure + (T.Scalar.Money.compare fill.fee T.Scalar.Money.zero >= 0) + "fill fee is negative" + in + ensure + (Option.is_some (T.Oms.find oms fill.order_id)) + "fill refers to an absent order" + | _ -> Ok () + +let check_slice risk history state audits = + let* history = check_audits history audits in + let* valuation = slice_valuation audits in + let* () = check_valuation risk valuation in + let oms = Generated_runner.oms state in + let* () = check_orders history oms in + let* () = + List.fold_left + (fun result audit -> + let* () = result in + check_fill oms audit) + (Ok ()) audits + in + Ok history + +let property_failure trace slice message = + QCheck2.Test.fail_reportf "slice %d: %s\nshrunk scenario:\n%s" slice message + (print_trace trace) + +let reducer_invariants_hold trace = + let risk = make_risk trace in + let config = make_config trace risk in + let state = + Generated_runner.create ~run_id:(run_id "property-run") ~scenario_sha256 + ~config + ~initial_portfolio:(initial_portfolio ~cash:initial_cash ()) + ~strategy_state:(schedule trace) + |> ok + in + let rec loop index history state = function + | [] -> ( + match Generated_runner.complete state with + | Error message -> property_failure trace index message + | Ok (_, valuation, audits) -> ( + match check_audits history audits with + | Error message -> property_failure trace index message + | Ok _ -> ( + let completed = + List.find_map + (fun audit -> + match audit.T.Audit.event with + | T.Audit.Run_completed { valuation; _ } -> Some valuation + | _ -> None) + audits + in + match completed with + | None -> + property_failure trace index "missing completion audit" + | Some completed -> ( + match + ( money_equal "completion equity" valuation.equity + completed.account.equity, + check_valuation risk completed ) + with + | Ok (), Ok () -> true + | Error message, _ | _, Error message -> + property_failure trace index message)))) + | step :: rest -> ( + let slice = make_slice trace index step in + match Generated_runner.process_slice state slice with + | Error message -> property_failure trace index message + | Ok (state, audits) -> ( + match check_slice risk history state audits with + | Error message -> property_failure trace index message + | Ok history -> loop (index + 1) history state rest)) + in + loop 1 empty_history state trace.steps + +let account_signature account = + let cash = + T.Account.cash_balances account + |> List.map (fun (currency, amount) -> + currency ^ "=" ^ T.Scalar.Money.to_decimal_string amount) + in + let positions = + T.Account.positions account + |> List.map (fun (instrument_id, (position : T.Account.position)) -> + String.concat ":" + [ + T.Id.Instrument.to_string instrument_id; + T.Scalar.Quantity.to_decimal_string position.T.Account.quantity; + T.Scalar.Money.to_decimal_string position.cost_basis; + T.Scalar.Money.to_decimal_string position.realized_pnl; + T.Scalar.Money.to_decimal_string position.dividend_pnl; + T.Scalar.Money.to_decimal_string position.execution_fees; + T.Scalar.Money.to_decimal_string position.borrow_fees; + ]) + in + cash @ positions + +let order_signature oms = + T.Oms.orders oms + |> List.map (fun order -> + String.concat ":" + [ + T.Id.Order.to_string order.T.Order.id; + T.Id.Instrument.to_string order.request.instrument_id; + side_name order.request.side; + T.Scalar.Quantity.to_decimal_string order.request.quantity; + T.Scalar.Quantity.to_decimal_string order.filled_quantity; + T.Scalar.Money.to_decimal_string order.filled_notional; + T.Order.status_to_string order.status; + ]) + +let rec drive_interactive strategy_state progress = + match T.Engine.Interactive.strategy_request progress with + | Some (context, event) -> + let strategy_state, intents = + Generated_strategy.on_event strategy_state context event + in + let* progress = T.Engine.Interactive.resume progress intents in + drive_interactive strategy_state progress + | None -> ( + match T.Engine.Interactive.slice_result progress with + | Some (state, audits) -> Ok (state, strategy_state, audits) + | None -> Error "interactive reducer reached an invalid progress state") + +let reducers_agree trace = + let risk = make_risk trace in + let config = make_config trace risk in + let strategy_state = schedule trace in + let scripted = + Generated_runner.create + ~run_id:(run_id "property-equivalence") + ~scenario_sha256 ~config + ~initial_portfolio:(initial_portfolio ~cash:initial_cash ()) + ~strategy_state + |> ok + in + let interactive = + T.Engine.Interactive.create + ~run_id:(run_id "property-equivalence") + ~scenario_sha256 ~config + ~initial_portfolio:(initial_portfolio ~cash:initial_cash ()) + |> ok + in + let compare_slice index scripted interactive audits scripted_audits = + let expected = List.map T.Codec.audit_to_string scripted_audits in + let actual = List.map T.Codec.audit_to_string audits in + if not (List.equal String.equal expected actual) then + property_failure trace index "scripted and interactive audits differ" + else if + not + (List.equal String.equal + (account_signature (Generated_runner.account scripted)) + (account_signature (T.Engine.Interactive.account interactive))) + then property_failure trace index "scripted and interactive accounts differ" + else if + not + (List.equal String.equal + (order_signature (Generated_runner.oms scripted)) + (order_signature (T.Engine.Interactive.oms interactive))) + then property_failure trace index "scripted and interactive orders differ" + else true + in + let rec loop index scripted interactive strategy_state = function + | [] -> ( + match + ( Generated_runner.complete scripted, + T.Engine.Interactive.complete interactive ) + with + | ( Ok (_, scripted_valuation, scripted_audits), + Ok (_, interactive_valuation, interactive_audits) ) -> + if + List.equal String.equal + (List.map T.Codec.audit_to_string scripted_audits) + (List.map T.Codec.audit_to_string interactive_audits) + && T.Scalar.Money.equal scripted_valuation.equity + interactive_valuation.equity + then true + else property_failure trace index "completion results differ" + | Error message, _ | _, Error message -> + property_failure trace index message) + | step :: rest -> ( + let slice = make_slice trace index step in + match + ( Generated_runner.process_slice scripted slice, + T.Engine.Interactive.process_slice interactive slice ) + with + | Ok (scripted, scripted_audits), Ok progress -> ( + match drive_interactive strategy_state progress with + | Error message -> property_failure trace index message + | Ok (interactive, strategy_state, audits) -> + if + compare_slice index scripted interactive audits + scripted_audits + then loop (index + 1) scripted interactive strategy_state rest + else false) + | Error message, _ | _, Error message -> + property_failure trace index message) + in + loop 1 scripted interactive strategy_state trace.steps + +let trace_shape trace = + let command_count = + List.fold_left + (fun total step -> total + List.length step.commands) + 0 trace.steps + in + Printf.sprintf "steps=%d commands=%d split=%b" (List.length trace.steps) + command_count + (Option.is_some trace.split_asset) + +let property_count default = + match Sys.getenv_opt "REDUCER_PROPERTY_CASES" with + | None -> default + | Some value -> ( + match int_of_string_opt value with + | Some count when count > 0 -> count + | _ -> invalid_arg "REDUCER_PROPERTY_CASES must be a positive integer") + +let reducer_invariant_property = + QCheck2.Test.make ~name:"generated reducer traces reconcile after every slice" + ~count:(property_count 300) ~print:print_trace ~collect:trace_shape + gen_trace reducer_invariants_hold + +let reducer_equivalence_property = + QCheck2.Test.make + ~name:"generated scripted and interactive reducer traces agree" + ~count:(property_count 250) ~print:print_trace ~collect:trace_shape + gen_trace reducers_agree + +let tests = + [ + QCheck_alcotest.to_alcotest ~speed_level:`Quick reducer_invariant_property; + QCheck_alcotest.to_alcotest ~speed_level:`Quick reducer_equivalence_property; + ] diff --git a/test/test_release_artifacts.py b/test/test_release_artifacts.py new file mode 100644 index 0000000..b098992 --- /dev/null +++ b/test/test_release_artifacts.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import importlib.util +import json +import pathlib +import tempfile +import unittest + + +REPOSITORY_ROOT = pathlib.Path(__file__).resolve().parent.parent +MODULE_PATH = REPOSITORY_ROOT / "scripts" / "release_artifacts.py" +SPEC = importlib.util.spec_from_file_location("release_artifacts", MODULE_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"cannot load {MODULE_PATH}") +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class ReleaseArtifactsTest(unittest.TestCase): + def create_root(self, directory: pathlib.Path) -> pathlib.Path: + root = directory / "repository" + (root / "requirements").mkdir(parents=True) + (root / "trading_engine.opam.locked").write_text( + 'depends: [\n "ocaml" {= "5.5.0"}\n]\n', encoding="utf-8" + ) + (root / "requirements" / "docs.lock").write_text( + "mkdocs==1.6.1\n", encoding="utf-8" + ) + (root / "requirements" / "schema.lock").write_text( + "jsonschema==4.26.0\n", encoding="utf-8" + ) + return root + + def test_generates_deterministic_spdx_and_provenance(self) -> None: + with tempfile.TemporaryDirectory() as directory_name: + directory = pathlib.Path(directory_name) + root = self.create_root(directory) + output = directory / "release" + output.mkdir() + names = MODULE.artifact_names("1.2.3", "linux-x86_64") + for index, name in enumerate(names.values()): + (output / name).write_bytes(f"artifact-{index}".encode()) + + arguments = (root, output, "1.2.3", "linux-x86_64", "a" * 40, 1234) + MODULE.generate_metadata(*arguments) + first = {path.name: path.read_bytes() for path in output.iterdir()} + MODULE.generate_metadata(*arguments) + second = {path.name: path.read_bytes() for path in output.iterdir()} + + self.assertEqual(first, second) + self.assertEqual( + set(MODULE.verify_checksums(output, "SUBJECTS.sha256")), + set(names.values()), + ) + sbom = json.loads((output / "sbom.spdx.json").read_text()) + self.assertEqual(sbom["spdxVersion"], "SPDX-2.3") + self.assertEqual( + {package["name"] for package in sbom["packages"]}, + {"trading-engine", "ocaml", "mkdocs", "jsonschema"}, + ) + provenance = json.loads( + (output / "provenance.intoto.jsonl").read_text() + ) + self.assertEqual( + provenance["predicateType"], "https://slsa.dev/provenance/v1" + ) + + def test_rejects_duplicate_and_mismatched_checksums(self) -> None: + with tempfile.TemporaryDirectory() as directory_name: + directory = pathlib.Path(directory_name) + artifact = directory / "artifact" + artifact.write_text("contents", encoding="utf-8") + digest = MODULE.sha256(artifact) + checksums = directory / "SHA256SUMS" + checksums.write_text( + f"{digest} artifact\n{digest} artifact\n", encoding="utf-8" + ) + with self.assertRaisesRegex(ValueError, "duplicate checksum"): + MODULE.verify_checksums(directory, "SHA256SUMS") + + checksums.write_text(f"{'0' * 64} artifact\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "digest mismatch"): + MODULE.verify_checksums(directory, "SHA256SUMS") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_repository_metadata.py b/test/test_repository_metadata.py new file mode 100644 index 0000000..48cc887 --- /dev/null +++ b/test/test_repository_metadata.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import collections +import json +import pathlib +import re +import unittest + + +REPOSITORY_ROOT = pathlib.Path(__file__).resolve().parent.parent +GITHUB = REPOSITORY_ROOT / ".github" +FORM_NAMES = {"bug.yml", "contract-change.yml", "cross-repository.yml", "feature.yml"} +DEFAULT_LABELS = {"bug", "enhancement"} + + +class RepositoryMetadataTest(unittest.TestCase): + def test_repository_profile_is_specific_and_bounded(self) -> None: + profile = json.loads((GITHUB / "repository.json").read_text(encoding="utf-8")) + + self.assertEqual( + profile["description"], + "Deterministic OCaml trading replay engine", + ) + self.assertEqual(profile["homepage"], "https://fallblu.github.io/trading-engine/") + self.assertEqual( + profile["topics"], + [ + "backtesting", + "deterministic", + "event-driven", + "execution-simulator", + "json-schema", + "ocaml", + "quantitative-finance", + "trading-engine", + ], + ) + self.assertEqual(len(profile["topics"]), len(set(profile["topics"]))) + self.assertFalse(profile["allow_merge_commit"]) + self.assertTrue(profile["allow_rebase_merge"]) + self.assertFalse(profile["allow_squash_merge"]) + self.assertTrue(profile["delete_branch_on_merge"]) + + def test_main_branch_protection_matches_integration_policy(self) -> None: + manifest = json.loads((GITHUB / "branch-protection.json").read_text(encoding="utf-8")) + + self.assertEqual(set(manifest["branches"]), {"main"}) + policy = manifest["branches"]["main"] + self.assertEqual( + policy["required_status_checks"], + {"strict": True, "contexts": ["check", "persistra-compatibility"]}, + ) + self.assertEqual( + policy["required_pull_request_reviews"], + { + "dismiss_stale_reviews": False, + "require_code_owner_reviews": False, + "required_approving_review_count": 0, + "require_last_push_approval": False, + }, + ) + self.assertTrue(policy["enforce_admins"]) + self.assertTrue(policy["required_conversation_resolution"]) + self.assertTrue(policy["required_linear_history"]) + self.assertFalse(policy["allow_force_pushes"]) + self.assertFalse(policy["allow_deletions"]) + self.assertIsNone(policy["restrictions"]) + + def test_label_manifest_covers_stable_planning_dimensions(self) -> None: + labels = json.loads((GITHUB / "labels.json").read_text(encoding="utf-8")) + names = [label["name"] for label in labels] + categories = collections.Counter(label["category"] for label in labels) + + self.assertEqual(len(names), len(set(names))) + self.assertEqual( + categories, + {"component": 10, "priority": 4, "effort": 3, "contract": 2, "dependency": 3}, + ) + self.assertTrue(all(re.fullmatch(r"[0-9a-f]{6}", label["color"]) for label in labels)) + self.assertTrue(all(label["description"].strip() for label in labels)) + self.assertIn("dependency: persistra", names) + self.assertIn("contract: scenario-v1", names) + self.assertIn("contract: strategy-v1", names) + + def test_structured_forms_reference_defined_labels_and_require_evidence(self) -> None: + template_directory = GITHUB / "ISSUE_TEMPLATE" + forms = {path.name: path for path in template_directory.glob("*.yml") if path.name != "config.yml"} + labels = json.loads((GITHUB / "labels.json").read_text(encoding="utf-8")) + allowed_labels = DEFAULT_LABELS | {label["name"] for label in labels} + + self.assertEqual(set(forms), FORM_NAMES) + for name, path in forms.items(): + text = path.read_text(encoding="utf-8") + self.assertRegex(text, r"(?m)^name: .+$", name) + self.assertRegex(text, r"(?m)^description: .+$", name) + self.assertIn("\nbody:\n", text, name) + self.assertIn("validations:\n required: true", text, name) + label_match = re.search(r"(?m)^labels: \[(.+)\]$", text) + self.assertIsNotNone(label_match, name) + assigned = set(json.loads(f"[{label_match.group(1)}]")) + self.assertLessEqual(assigned, allowed_labels, name) + ids = re.findall(r"(?m)^ id: ([a-z0-9-]+)$", text) + self.assertEqual(len(ids), len(set(ids)), name) + self.assertNotIn("priority:", "\n".join(path.read_text() for path in forms.values())) + self.assertNotIn("effort:", "\n".join(path.read_text() for path in forms.values())) + + config = (template_directory / "config.yml").read_text(encoding="utf-8") + self.assertIn("blank_issues_enabled: false", config) + self.assertIn(".github/SUPPORT.md", config) + + def test_pull_request_and_support_templates_preserve_required_sections(self) -> None: + pull_request = (GITHUB / "pull_request_template.md").read_text(encoding="utf-8") + self.assertEqual(pull_request.count("## Summary"), 1) + self.assertEqual(pull_request.count("## Test plan"), 1) + + support = (GITHUB / "SUPPORT.md").read_text(encoding="utf-8") + self.assertIn("structured issue forms", support) + self.assertIn("Do not post credentials", support) + + def test_compatibility_gate_is_pinned_and_canary_is_optional(self) -> None: + workflow = (GITHUB / "workflows/ci.yml").read_text(encoding="utf-8") + revision = "2f8beeaab87f4c456f7040f327314afe67306a54" + + self.assertIn(f"PERSISTRA_COMPAT_REVISION: {revision}", workflow) + self.assertIn("ref: ${{ env.PERSISTRA_COMPAT_REVISION }}", workflow) + self.assertNotIn("PERSISTRA_COMPAT_REF", workflow) + self.assertNotIn("vars.", workflow) + self.assertIn('test "$actual_revision" = "$PERSISTRA_COMPAT_REVISION"', workflow) + self.assertIn("Persistra compatibility revision: $actual_revision", workflow) + self.assertIn('>> "$GITHUB_STEP_SUMMARY"', workflow) + + canary = workflow.split(" persistra-latest-head:\n", 1)[1] + self.assertIn("workflow_dispatch", workflow) + self.assertIn("inputs.persistra_latest_head", canary) + self.assertIn("continue-on-error: true", canary) + self.assertIn("ref: develop", canary) + self.assertIn("Persistra latest-head canary revision", canary) + + revisions = re.findall(r"uses: [^@\s]+@([^\s]+)", workflow) + self.assertTrue(revisions) + self.assertTrue(all(re.fullmatch(r"[0-9a-f]{40}", revision) for revision in revisions)) + + compatibility = (REPOSITORY_ROOT / "docs/persistra.md").read_text(encoding="utf-8") + self.assertIn("versioned files and processes", compatibility) + self.assertIn("scenario contract v1", compatibility) + self.assertIn("explicit pair of repository commits", compatibility) + self.assertIn("Neither repository", compatibility) + self.assertIn("silently follows a moving branch", compatibility) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_risk_groups.ml b/test/test_risk_groups.ml new file mode 100644 index 0000000..891d1cb --- /dev/null +++ b/test/test_risk_groups.ml @@ -0,0 +1,697 @@ +open Test_support +module T = Trading_engine + +let policy instrument ?(max_order = "100") ?(max_long = "100") + ?(max_short = "100") ?(max_notional = "100000") ?(initial_margin_bps = 5000) + ?(maintenance_margin_bps = 2500) ?(shorting_allowed = true) () = + T.Risk.create_instrument_policy ~instrument + ~max_order_quantity:(quantity max_order) + ~max_long_position:(quantity max_long) + ~max_short_position:(quantity max_short) + ~max_notional_exposure:(Some (money max_notional)) + ~initial_margin_bps ~maintenance_margin_bps ~shorting_allowed + |> ok + +let limits ?gross ?long ?short ?absolute_net ?concentration () = + T.Risk.create_group_limits ~max_gross_exposure:(Option.map money gross) + ~max_long_exposure:(Option.map money long) + ~max_short_exposure:(Option.map money short) + ~max_absolute_net_exposure:(Option.map money absolute_net) + ~max_concentration: + (Option.map + (fun value -> T.Scalar.Ratio.of_decimal_string value |> ok) + concentration) + |> ok + +let group id instruments limits = + T.Risk.create_group + ~group_id:(T.Id.Risk_group.of_string_exn id) + ~group_kind:T.Risk.Issuer + ~instrument_ids:(List.map (fun item -> item.T.Instrument.id) instruments) + ~limits + |> ok + +let setup ?(groups = []) ?(shorting_allowed = true) () = + let first = instrument ~id:"first" ~symbol:"FIRST" () in + let second = instrument ~id:"second" ~symbol:"SECOND" () in + let policies = + [ + policy first ~shorting_allowed (); policy second ~shorting_allowed:true (); + ] + in + let risk = + T.Risk.create ~base_currency:"USD" ~instruments:[ first; second ] + ~instrument_policies:policies ~groups ~max_gross_exposure:(money "100000") + ~max_leverage:(T.Scalar.Ratio.of_decimal_string "10" |> ok) + |> ok + in + (first, second, risk) + +let exact_coverage_and_short_policy () = + let first = instrument ~id:"first" ~symbol:"FIRST" () in + let second = instrument ~id:"second" ~symbol:"SECOND" () in + let result = + T.Risk.create ~base_currency:"USD" ~instruments:[ first; second ] + ~instrument_policies:[ policy first () ] + ~groups:[] ~max_gross_exposure:(money "100000") + ~max_leverage:(T.Scalar.Ratio.of_decimal_string "10" |> ok) + in + Alcotest.(check string) + "policy required for every instrument" + "risk must define exactly one policy for every instrument" (error result); + let first, _, risk = setup ~shorting_allowed:false () in + let account = test_account () in + let request = + request ~instrument:first.id ~side:T.Order.Sell ~quantity_value:"1" () + in + let result = + T.Risk.check risk ~account ~oms:T.Oms.empty + ~marks:[ (first.id, price "100"); (instrument_id "second", price "100") ] + ~fx_rates:[ ("USD", price "1") ] + request + in + Alcotest.(check string) + "short prohibition is explicit" + "instrument policy does not allow short positions" (error result) + +let overlapping_groups_are_deterministic () = + let first = instrument ~id:"first" ~symbol:"FIRST" () in + let second = instrument ~id:"second" ~symbol:"SECOND" () in + let constrained = limits ~gross:"900" () in + let groups = + [ + group "z-group" [ first; second ] constrained; + group "a-group" [ first; second ] constrained; + ] + in + let _, _, risk = setup ~groups () in + let account = test_account () in + let first_request = + request ~instrument:first.id ~quantity_value:"5" + ~kind:(T.Order.Limit (price "100")) + () + in + let oms, _ = oms_with_order first_request in + let second_request = request ~instrument:second.id ~quantity_value:"5" () in + let result = + T.Risk.check risk ~account ~oms + ~marks:[ (first.id, price "100"); (second.id, price "100") ] + ~fx_rates:[ ("USD", price "1") ] + second_request + in + Alcotest.(check string) + "lexically first limiting group" + "position would exceed group a-group maximum gross exposure" (error result) + +let fill_reserves_remainder_and_reports_group () = + let first = instrument ~id:"first" ~symbol:"FIRST" () in + let second = instrument ~id:"second" ~symbol:"SECOND" () in + let groups = [ group "issuer" [ first; second ] (limits ~gross:"700" ()) ] in + let _, _, risk = setup ~groups () in + let account = test_account () in + let oms, order = + request ~instrument:first.id ~quantity_value:"10" () |> oms_with_order + in + let candidate = fill ~quantity_value:"5" order in + let account = T.Account.apply_fill account candidate |> ok in + let valuation = + account_value ~instruments:[ first; second ] account + ~marks:[ (first.id, price "100"); (second.id, price "100") ] + in + (match + T.Risk.check_reserved_fill risk ~account ~oms + ~marks:[ (first.id, price "100"); (second.id, price "100") ] + ~fx_rates:[ ("USD", price "1") ] + ~order ~filled_quantity:(quantity "5") ~after:valuation + with + | Error (T.Risk.Limit (T.Risk.Group_maximum_gross (id, limit))) -> + Alcotest.(check string) + "limiting group" "issuer" + (T.Id.Risk_group.to_string id); + Alcotest.check money_testable "group threshold" (money "700") limit + | _ -> Alcotest.fail "expected group gross fill limit"); + let snapshot = T.Risk.group_exposures risk valuation |> ok |> List.hd in + Alcotest.check money_testable "actual group gross" (money "500") + snapshot.gross_exposure + +let initialized_positions_use_instrument_margin_and_groups () = + let first = instrument ~id:"first" ~symbol:"FIRST" () in + let second = instrument ~id:"second" ~symbol:"SECOND" () in + let group_limit = limits ~gross:"150" () in + let risk = + T.Risk.create ~base_currency:"USD" ~instruments:[ first; second ] + ~instrument_policies: + [ + policy first ~initial_margin_bps:10_000 ~maintenance_margin_bps:5000 + (); + policy second ~initial_margin_bps:2500 ~maintenance_margin_bps:1000 (); + ] + ~groups:[ group "initial-group" [ first; second ] group_limit ] + ~max_gross_exposure:(money "100000") + ~max_leverage:(T.Scalar.Ratio.of_decimal_string "10" |> ok) + |> ok + in + let account = test_account () in + let first_order = + request ~instrument:first.id ~quantity_value:"1" () + |> accepted_order ~id:"first-order" + in + let second_order = + request ~instrument:second.id ~quantity_value:"1" () + |> accepted_order ~id:"second-order" + in + let account = + T.Account.apply_fill account (fill ~id:"first-fill" first_order) |> ok + in + let account = + T.Account.apply_fill account (fill ~id:"second-fill" second_order) |> ok + in + let valuation = + account_value ~instruments:[ first; second ] account + ~marks:[ (first.id, price "100"); (second.id, price "100") ] + in + let margin = T.Risk.margin_snapshot risk valuation |> ok in + Alcotest.check money_testable "per-instrument initial margin" (money "125") + margin.initial_requirement; + Alcotest.(check string) + "initialized group exposure enforced" + "initial portfolio exceeds group initial-group maximum gross exposure" + (T.Risk.check_initial risk valuation |> error) + +let reserved_result ?(side = T.Order.Buy) ?(initial_cash = "10000") + ?(max_long = "100") ?(max_short = "100") ?(max_notional = "100000") + ?(shorting_allowed = true) ?(initial_margin_bps = 5000) + ?(max_gross = "100000") ?(max_leverage = "10") ?group_limits + ?(include_mark = true) ?(include_fx = true) () = + let first = instrument ~id:"first" ~symbol:"FIRST" () in + let second = instrument ~id:"second" ~symbol:"SECOND" () in + let groups = + Option.to_list + (Option.map + (fun value -> group "group-a" [ first; second ] value) + group_limits) + in + let risk = + T.Risk.create ~base_currency:"USD" ~instruments:[ first; second ] + ~instrument_policies: + [ + policy first ~max_long ~max_short ~max_notional ~shorting_allowed + ~initial_margin_bps + ~maintenance_margin_bps:(min initial_margin_bps 2500) + (); + policy second (); + ] + ~groups ~max_gross_exposure:(money max_gross) + ~max_leverage:(T.Scalar.Ratio.of_decimal_string max_leverage |> ok) + |> ok + in + let account = test_account ~initial_cash:[ ("USD", money initial_cash) ] () in + let oms, order = + request ~instrument:first.id ~side ~quantity_value:"10" () |> oms_with_order + in + let candidate = fill ~quantity_value:"1" order in + let account = T.Account.apply_fill account candidate |> ok in + let valuation = + account_value ~instruments:[ first; second ] account + ~marks:[ (first.id, price "100"); (second.id, price "100") ] + in + T.Risk.check_reserved_fill risk ~account ~oms + ~marks: + (if include_mark then + [ (first.id, price "100"); (second.id, price "100") ] + else []) + ~fx_rates:(if include_fx then [ ("USD", price "1") ] else []) + ~order ~filled_quantity:(quantity "1") ~after:valuation + +let clipping_taxonomy_is_exact () = + let is_expected expected = function + | Error (T.Risk.Limit actual) when expected actual -> () + | _ -> Alcotest.fail "unexpected reserved fill result" + in + reserved_result ~max_long:"7" () + |> is_expected (function + | T.Risk.Instrument_maximum_long_position _ -> true + | _ -> false); + reserved_result ~side:T.Order.Sell ~max_short:"7" () + |> is_expected (function + | T.Risk.Instrument_maximum_short_position _ -> true + | _ -> false); + reserved_result ~max_notional:"700" () + |> is_expected (function + | T.Risk.Instrument_maximum_notional _ -> true + | _ -> false); + reserved_result ~side:T.Order.Sell ~shorting_allowed:false () + |> is_expected (function + | T.Risk.Instrument_shorting_disabled _ -> true + | _ -> false); + reserved_result ~max_gross:"700" () + |> is_expected (function + | T.Risk.Maximum_gross_exposure _ -> true + | _ -> false); + reserved_result ~max_leverage:"0.05" () + |> is_expected (function T.Risk.Maximum_leverage _ -> true | _ -> false); + reserved_result ~initial_cash:"500" ~initial_margin_bps:10_000 () + |> is_expected (function + | T.Risk.Instrument_initial_margin _ -> true + | _ -> false); + reserved_result ~group_limits:(limits ~long:"700" ()) () + |> is_expected (function T.Risk.Group_maximum_long _ -> true | _ -> false); + reserved_result ~side:T.Order.Sell ~group_limits:(limits ~short:"700" ()) () + |> is_expected (function T.Risk.Group_maximum_short _ -> true | _ -> false); + reserved_result ~group_limits:(limits ~absolute_net:"700" ()) () + |> is_expected (function + | T.Risk.Group_maximum_absolute_net _ -> true + | _ -> false); + reserved_result ~group_limits:(limits ~concentration:"0.05" ()) () + |> is_expected (function + | T.Risk.Group_maximum_concentration _ -> true + | _ -> false) + +let constructors_reject_ambiguous_policies () = + let first = instrument ~id:"first" ~symbol:"FIRST" () in + let bad_policy ?(max_order = "10") ?(max_long = "10") ?(max_short = "10") + ?(notional = Some (money "100")) ?(initial = 5000) ?(maintenance = 2500) + () = + T.Risk.create_instrument_policy ~instrument:first + ~max_order_quantity:(quantity max_order) + ~max_long_position:(quantity max_long) + ~max_short_position:(quantity max_short) ~max_notional_exposure:notional + ~initial_margin_bps:initial ~maintenance_margin_bps:maintenance + ~shorting_allowed:true + in + List.iter + (fun result -> + Alcotest.(check bool) "invalid policy" true (Result.is_error result)) + [ + bad_policy ~max_order:"0" (); + bad_policy ~max_long:"0" (); + bad_policy ~max_short:"0" (); + bad_policy ~notional:(Some (money "0")) (); + bad_policy ~initial:0 (); + bad_policy ~initial:10_001 (); + bad_policy ~maintenance:0 (); + bad_policy ~maintenance:10_001 (); + bad_policy ~initial:1000 ~maintenance:2000 (); + ]; + List.iter + (fun result -> + Alcotest.(check bool) "invalid group limits" true (Result.is_error result)) + [ + T.Risk.create_group_limits ~max_gross_exposure:None + ~max_long_exposure:None ~max_short_exposure:None + ~max_absolute_net_exposure:None ~max_concentration:None; + T.Risk.create_group_limits + ~max_gross_exposure:(Some (money "0")) + ~max_long_exposure:None ~max_short_exposure:None + ~max_absolute_net_exposure:None ~max_concentration:None; + T.Risk.create_group_limits ~max_gross_exposure:None + ~max_long_exposure:None ~max_short_exposure:None + ~max_absolute_net_exposure:None + ~max_concentration:(Some (T.Scalar.Ratio.of_decimal_string "2" |> ok)); + ]; + let valid_limits = limits ~gross:"100" () in + Alcotest.(check bool) + "empty group rejected" true + (Result.is_error + (T.Risk.create_group + ~group_id:(T.Id.Risk_group.of_string_exn "group") + ~group_kind:T.Risk.Custom ~instrument_ids:[] ~limits:valid_limits)); + Alcotest.(check bool) + "duplicate membership rejected" true + (Result.is_error + (T.Risk.create_group + ~group_id:(T.Id.Risk_group.of_string_exn "group") + ~group_kind:T.Risk.Custom ~instrument_ids:[ first.id; first.id ] + ~limits:valid_limits)) + +let create_rejects_inconsistent_configuration () = + let first = instrument ~id:"first" ~symbol:"FIRST" () in + let second = instrument ~id:"second" ~symbol:"SECOND" () in + let first_policy = policy first () in + let second_policy = policy second () in + let unknown = instrument ~id:"unknown" ~symbol:"UNKNOWN" () in + let unknown_policy = policy unknown () in + let valid_group = group "group" [ first ] (limits ~gross:"100" ()) in + let unknown_group = + group "unknown-group" [ unknown ] (limits ~gross:"100" ()) + in + let create ?(base_currency = "USD") ?(instruments = [ first; second ]) + ?(policies = [ first_policy; second_policy ]) ?(groups = [ valid_group ]) + ?(max_gross = "100000") () = + T.Risk.create ~base_currency ~instruments ~instrument_policies:policies + ~groups ~max_gross_exposure:(money max_gross) + ~max_leverage:(T.Scalar.Ratio.of_decimal_string "10" |> ok) + in + List.iter + (fun result -> + Alcotest.(check bool) + "invalid risk configuration" true (Result.is_error result)) + [ + create ~base_currency:"" (); + create ~instruments:[] ~policies:[] ~groups:[] (); + create ~max_gross:"0" (); + create ~instruments:[ first; first ] (); + create ~policies:[ first_policy; unknown_policy ] (); + create ~policies:[ first_policy; first_policy ] (); + create ~policies:[ first_policy ] (); + create ~groups:[ valid_group; valid_group ] (); + create ~groups:[ unknown_group ] (); + ] + +let admission_result ?(side = T.Order.Buy) ?(initial_cash = "10000") + ?(max_order = "100") ?(max_long = "100") ?(max_short = "100") + ?(max_notional = "100000") ?(shorting_allowed = true) + ?(initial_margin_bps = 5000) ?(max_gross = "100000") ?(max_leverage = "10") + ?group_limits () = + let first = instrument ~id:"first" ~symbol:"FIRST" () in + let second = instrument ~id:"second" ~symbol:"SECOND" () in + let groups = + Option.to_list + (Option.map + (fun value -> group "group-a" [ first; second ] value) + group_limits) + in + let risk = + T.Risk.create ~base_currency:"USD" ~instruments:[ first; second ] + ~instrument_policies: + [ + policy first ~max_order ~max_long ~max_short ~max_notional + ~shorting_allowed ~initial_margin_bps + ~maintenance_margin_bps:(min initial_margin_bps 2500) + (); + policy second (); + ] + ~groups ~max_gross_exposure:(money max_gross) + ~max_leverage:(T.Scalar.Ratio.of_decimal_string max_leverage |> ok) + |> ok + in + T.Risk.check risk + ~account:(test_account ~initial_cash:[ ("USD", money initial_cash) ] ()) + ~oms:T.Oms.empty + ~marks:[ (first.id, price "100"); (second.id, price "100") ] + ~fx_rates:[ ("USD", price "1") ] + (request ~instrument:first.id ~side ~quantity_value:"10" ()) + +let admission_enforces_every_limit () = + let check_error expected result = + Alcotest.(check string) "admission error" expected (error result) + in + admission_result ~max_order:"7" () + |> check_error "order exceeds the instrument maximum order quantity"; + admission_result ~max_long:"7" () + |> check_error "position would exceed the instrument maximum long position"; + admission_result ~side:T.Order.Sell ~max_short:"7" () + |> check_error "position would exceed the instrument maximum short position"; + admission_result ~side:T.Order.Sell ~shorting_allowed:false () + |> check_error "instrument policy does not allow short positions"; + admission_result ~max_notional:"700" () + |> check_error + "position would exceed the instrument maximum notional exposure"; + admission_result ~max_gross:"700" () + |> check_error "portfolio would exceed maximum gross exposure"; + admission_result ~max_leverage:"0.05" () + |> check_error "portfolio would exceed maximum leverage"; + admission_result ~initial_cash:"500" ~initial_margin_bps:10_000 () + |> check_error + "portfolio would violate instrument initial margin requirements"; + admission_result ~group_limits:(limits ~long:"700" ()) () + |> check_error "position would exceed group group-a maximum long exposure"; + admission_result ~side:T.Order.Sell ~group_limits:(limits ~short:"700" ()) () + |> check_error "position would exceed group group-a maximum short exposure"; + admission_result ~group_limits:(limits ~absolute_net:"700" ()) () + |> check_error + "position would exceed group group-a maximum absolute net exposure"; + admission_result ~group_limits:(limits ~concentration:"0.05" ()) () + |> check_error "position would exceed group group-a maximum concentration" + +let group_exposures_include_short_and_zero_equity () = + let first = instrument ~id:"first" ~symbol:"FIRST" () in + let second = instrument ~id:"second" ~symbol:"SECOND" () in + let risk = + T.Risk.create ~base_currency:"USD" ~instruments:[ first; second ] + ~instrument_policies:[ policy first (); policy second () ] + ~groups:[ group "group" [ first ] (limits ~gross:"100000" ()) ] + ~max_gross_exposure:(money "100000") + ~max_leverage:(T.Scalar.Ratio.of_decimal_string "10" |> ok) + |> ok + in + let order = + request ~instrument:first.id ~side:T.Order.Sell ~quantity_value:"1" () + |> accepted_order + in + let account = + test_account ~initial_cash:[ ("USD", money "0") ] () |> fun account -> + T.Account.apply_fill account (fill order) |> ok + in + let valuation = + account_value ~instruments:[ first; second ] account + ~marks:[ (first.id, price "100"); (second.id, price "100") ] + in + let exposure = T.Risk.group_exposures risk valuation |> ok |> List.hd in + Alcotest.check money_testable "short exposure" (money "100") + exposure.short_exposure; + Alcotest.(check bool) + "zero-equity concentration omitted" true + (Option.is_none exposure.concentration) + +let initial_result ?(side = T.Order.Buy) ?(initial_cash = "10000") + ?(max_long = "100") ?(max_short = "100") ?(max_notional = "100000") + ?(shorting_allowed = true) ?(initial_margin_bps = 5000) + ?(max_gross = "100000") ?(max_leverage = "10") ?group_limits () = + let first = instrument ~id:"first" ~symbol:"FIRST" () in + let second = instrument ~id:"second" ~symbol:"SECOND" () in + let groups = + Option.to_list + (Option.map + (fun value -> group "group-a" [ first; second ] value) + group_limits) + in + let risk = + T.Risk.create ~base_currency:"USD" ~instruments:[ first; second ] + ~instrument_policies: + [ + policy first ~max_long ~max_short ~max_notional ~shorting_allowed + ~initial_margin_bps + ~maintenance_margin_bps:(min initial_margin_bps 2500) + (); + policy second (); + ] + ~groups ~max_gross_exposure:(money max_gross) + ~max_leverage:(T.Scalar.Ratio.of_decimal_string max_leverage |> ok) + |> ok + in + let order = + request ~instrument:first.id ~side ~quantity_value:"10" () |> accepted_order + in + let account = + test_account ~initial_cash:[ ("USD", money initial_cash) ] () + |> fun account -> + T.Account.apply_fill account (fill ~quantity_value:"10" order) |> ok + in + let valuation = + account_value ~instruments:[ first; second ] account + ~marks:[ (first.id, price "100"); (second.id, price "100") ] + in + T.Risk.check_initial risk valuation + +let initial_portfolio_enforces_every_limit () = + let check_error expected result = + Alcotest.(check string) "initial portfolio error" expected (error result) + in + initial_result ~max_gross:"700" () + |> check_error "portfolio would exceed maximum gross exposure"; + initial_result ~max_leverage:"0.05" () + |> check_error "portfolio would exceed maximum leverage"; + initial_result ~max_long:"7" () + |> check_error "initial position exceeds its maximum long position"; + initial_result ~side:T.Order.Sell ~max_short:"7" () + |> check_error "initial position exceeds its maximum short position"; + initial_result ~side:T.Order.Sell ~shorting_allowed:false () + |> check_error "initial position violates its shorting policy"; + initial_result ~max_notional:"700" () + |> check_error + "initial position exceeds the instrument maximum notional exposure"; + initial_result ~initial_cash:"500" ~initial_margin_bps:10_000 () + |> check_error + "portfolio would violate instrument initial margin requirements"; + initial_result ~group_limits:(limits ~long:"700" ()) () + |> check_error "initial portfolio exceeds group group-a maximum long exposure"; + initial_result ~side:T.Order.Sell ~group_limits:(limits ~short:"700" ()) () + |> check_error + "initial portfolio exceeds group group-a maximum short exposure"; + initial_result ~group_limits:(limits ~absolute_net:"700" ()) () + |> check_error + "initial portfolio exceeds group group-a maximum absolute net exposure"; + initial_result ~group_limits:(limits ~concentration:"0.05" ()) () + |> check_error "initial portfolio exceeds group group-a maximum concentration" + +let risk_and_policy_boundaries_are_rejected () = + let first = instrument ~id:"first" ~symbol:"FIRST" () in + let large_lot = instrument ~id:"large" ~symbol:"LARGE" ~lot_size:"2" () in + let ratio = T.Scalar.Ratio.of_decimal_string "10" |> ok in + let create ?(base_currency = "USD") ?(instruments = [ first ]) + ?(max_order = "10") ?(max_long = "10") ?(max_short = "10") + ?(max_gross = "1000") ?(initial = 5000) ?(maintenance = 2500) () = + let instrument_policies = + List.map + (fun instrument -> + T.Risk.create_instrument_policy ~instrument + ~max_order_quantity:(quantity max_order) + ~max_long_position:(quantity max_long) + ~max_short_position:(quantity max_short) ~max_notional_exposure:None + ~initial_margin_bps:initial ~maintenance_margin_bps:maintenance + ~shorting_allowed:true) + instruments + in + match List.find_opt Result.is_error instrument_policies with + | Some (Error message) -> Error message + | Some (Ok _) -> assert false + | None -> + T.Risk.create ~base_currency ~instruments + ~instrument_policies:(List.map Result.get_ok instrument_policies) + ~groups:[] ~max_gross_exposure:(money max_gross) ~max_leverage:ratio + in + List.iter + (fun result -> + Alcotest.(check bool) "invalid risk" true (Result.is_error result)) + [ + create ~base_currency:"" (); + create ~max_order:"0" (); + create ~max_long:"0" (); + create ~max_short:"0" (); + create ~max_gross:"0" (); + create ~initial:0 (); + create ~maintenance:0 (); + create ~initial:10_001 (); + create ~maintenance:10_001 (); + create ~initial:1000 ~maintenance:2000 (); + create ~instruments:[] (); + create ~instruments:[ large_lot ] ~max_order:"1" (); + create ~instruments:[ large_lot ] ~max_long:"1" (); + create ~instruments:[ large_lot ] ~max_short:"1" (); + create ~instruments:[ first; first ] (); + ]; + let invalid_policy value selector = + T.Risk.create_instrument_policy ~instrument:large_lot + ~max_order_quantity:(quantity (selector "order" value)) + ~max_long_position:(quantity (selector "long" value)) + ~max_short_position:(quantity (selector "short" value)) + ~max_notional_exposure:None ~initial_margin_bps:5000 + ~maintenance_margin_bps:2500 ~shorting_allowed:true + in + let selected field value target = + if String.equal field target then value else "10" + in + List.iter + (fun result -> + Alcotest.(check bool) "policy below lot" true (Result.is_error result)) + [ + invalid_policy "1" (fun field value -> selected field value "order"); + invalid_policy "1" (fun field value -> selected field value "long"); + invalid_policy "1" (fun field value -> selected field value "short"); + ] + +let public_checks_cover_success_and_diagnostics () = + let configured = risk ~max_order:"7" ~max_long:"7" ~max_short:"7" () in + Alcotest.(check bool) + "position accepted" true + (Result.is_ok (T.Risk.check_position configured (quantity "7"))); + Alcotest.(check string) + "long rejected" "position would exceed the maximum long position" + (T.Risk.check_position configured (quantity "8") |> error); + Alcotest.(check string) + "short rejected" "position would exceed the maximum short position" + (T.Risk.check_position configured (quantity "-8") |> error); + Alcotest.(check string) + "unknown policy" "position refers to an unknown instrument risk policy" + (T.Risk.check_position_for configured (instrument_id "unknown") + (quantity "1") + |> error); + let unknown_request = request ~instrument:(instrument_id "unknown") () in + Alcotest.(check string) + "unknown instrument" "order refers to an unknown instrument" + (T.Risk.check configured ~account:(test_account ()) ~oms:T.Oms.empty + ~marks:[] ~fx_rates:[] unknown_request + |> error); + Alcotest.(check string) + "order limit" "order exceeds the instrument maximum order quantity" + (risk_check configured ~account:(test_account ()) ~oms:T.Oms.empty + (request ~quantity_value:"8" ()) + |> error); + (match reserved_result ~include_mark:false () with + | Error (T.Risk.Invalid message) -> + Alcotest.(check string) + "missing mark" "projected position has no current market price" message + | _ -> Alcotest.fail "expected missing mark diagnostic"); + (match reserved_result ~include_fx:false () with + | Error (T.Risk.Invalid message) -> + Alcotest.(check string) + "missing FX" "projected position has no current FX rate" message + | _ -> Alcotest.fail "expected missing FX diagnostic"); + Alcotest.(check bool) + "unconstrained group fill accepted" true + (Result.is_ok (reserved_result ~group_limits:(limits ~gross:"10000" ()) ())); + Alcotest.(check bool) + "concentration-compliant fill accepted" true + (Result.is_ok + (reserved_result ~group_limits:(limits ~concentration:"1" ()) ())); + Alcotest.(check bool) + "unconstrained group admission accepted" true + (Result.is_ok + (admission_result ~group_limits:(limits ~gross:"10000" ()) ())) + +let post_fill_covers_gross_and_reduction () = + let first = instrument () in + let before_account = test_account () in + let before = + account_value before_account ~marks:[ (first.id, price "100") ] + in + let order = + request ~instrument:first.id ~quantity_value:"10" () |> accepted_order + in + let after_account = + T.Account.apply_fill before_account (fill ~quantity_value:"10" order) |> ok + in + let after = account_value after_account ~marks:[ (first.id, price "100") ] in + let limited = risk ~max_gross:"700" () in + (match + T.Risk.check_post_fill limited ~before_position:(quantity "0") + ~after_position:(quantity "10") ~before ~after + with + | Error (T.Risk.Limit (T.Risk.Maximum_gross_exposure _)) -> () + | _ -> Alcotest.fail "expected gross fill limit"); + Alcotest.(check bool) + "gross-reducing fill accepted" true + (Result.is_ok + (T.Risk.check_post_fill limited ~before_position:(quantity "10") + ~after_position:(quantity "9") ~before:after ~after:before)) + +let tests = + [ + Alcotest.test_case "exact policy coverage and short prohibition" `Quick + exact_coverage_and_short_policy; + Alcotest.test_case "overlapping groups use deterministic IDs" `Quick + overlapping_groups_are_deterministic; + Alcotest.test_case "fill reserves remainder and reports group" `Quick + fill_reserves_remainder_and_reports_group; + Alcotest.test_case "initialized positions use exact risk policies" `Quick + initialized_positions_use_instrument_margin_and_groups; + Alcotest.test_case "clipping taxonomy is exact" `Quick + clipping_taxonomy_is_exact; + Alcotest.test_case "constructors reject ambiguous policies" `Quick + constructors_reject_ambiguous_policies; + Alcotest.test_case "current constructor rejects inconsistent configuration" + `Quick create_rejects_inconsistent_configuration; + Alcotest.test_case "admission enforces every current limit" `Quick + admission_enforces_every_limit; + Alcotest.test_case "group exposures include short and zero equity" `Quick + group_exposures_include_short_and_zero_equity; + Alcotest.test_case "initial portfolio enforces every current limit" `Quick + initial_portfolio_enforces_every_limit; + Alcotest.test_case "risk and policy boundaries are rejected" `Quick + risk_and_policy_boundaries_are_rejected; + Alcotest.test_case "public checks cover success and diagnostics" `Quick + public_checks_cover_success_and_diagnostics; + Alcotest.test_case "post-fill covers gross and reduction" `Quick + post_fill_covers_gross_and_reduction; + ] diff --git a/test/test_scenario.ml b/test/test_scenario.ml index a8c6f08..1cf7428 100644 --- a/test/test_scenario.ml +++ b/test/test_scenario.ml @@ -2,12 +2,21 @@ open Test_support module T = Trading_engine let demo_document () = - In_channel.with_open_bin "../contracts/v3/fixtures/demo.scenario.json" + In_channel.with_open_bin "../contracts/v1/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/v3/fixtures/demo.scenario.jsonl" +let stream_path = "../contracts/v1/fixtures/demo.scenario.jsonl" +let quote_trade_path = "../contracts/v1/fixtures/quote-trade.scenario.json" + +let quote_trade_stream_path = + "../contracts/v1/fixtures/quote-trade.scenario.jsonl" + +let order_book_path = "../contracts/v1/fixtures/order-book.scenario.json" + +let order_book_stream_path = + "../contracts/v1/fixtures/order-book.scenario.jsonl" let stream_document () = In_channel.with_open_bin stream_path In_channel.input_all @@ -25,6 +34,22 @@ let with_stream records function_ = output_string channel (String.concat "\n" records ^ "\n")); function_ path) +let with_stream_document document function_ = + let path = Filename.temp_file "trading-engine-scenario" ".jsonl" in + Fun.protect + ~finally:(fun () -> if Sys.file_exists path then Sys.remove path) + (fun () -> + Out_channel.with_open_bin path (fun channel -> + output_string channel document); + function_ path) + +let fold_stream_with_limit maximum path = + In_channel.with_open_bin path (fun channel -> + T.Scenario_stream.fold_channel ~max_record_bytes:maximum channel + ~init:(fun _ -> Ok ()) + ~step:(fun () _ -> Ok ()) + ~finish:(fun () ~slice_count -> Ok slice_count)) + let add_seconds timestamp seconds = Ptime.add_span timestamp (Ptime.Span.of_int_s seconds) |> Option.get @@ -46,8 +71,20 @@ let write_large_stream path slice_count = for index = 1 to slice_count do let offset = (index - 1) * 4 in let market_slice = - T.Market_slice.create ~slice_sequence:(Int64.of_int index) - ~start_at:(add_seconds base offset) + let start_at = add_seconds base offset in + let borrow_observation = + T.Financing.borrow_observation + ~instrument_id:(instrument_id "demo-equity-acme") + ~effective_at:start_at ~available_quantity:(quantity "1000") + ~annual_rate_bps:100 ~recalled:false + |> ok + in + let cash_rate = + T.Financing.cash_rate_observation ~currency:"USD" + ~effective_at:start_at ~credit_rate_bps:0 ~debit_rate_bps:0 + |> ok + in + T.Market_slice.create ~slice_sequence:(Int64.of_int index) ~start_at ~end_at:(add_seconds base (offset + 1)) ~available_at:(add_seconds base (offset + 2)) ~received_at:(add_seconds base (offset + 3)) @@ -58,7 +95,9 @@ let write_large_stream path slice_count = (Int64.of_int index); ] ~fx_rates:[ fx_mark () ] - ~corporate_actions:[] + ~corporate_actions:[] ~borrow_observations:[ borrow_observation ] + ~cash_rate_observations:[ cash_rate ] ~settlement_failures:[] + ~lifecycle_events:[] ~market_events:[] ~order_book_events:[] |> ok in let payload = @@ -85,7 +124,7 @@ let demo_contract_parses () = Alcotest.(check int) "one instrument" 1 (List.length scenario.instruments); Alcotest.(check int) "four slices" 4 (List.length scenario.slices); Alcotest.(check string) - "execution model" "completed_bar_v1" + "execution model" "completed_bar_adverse_touch_v1" (T.Execution_model.name scenario.execution_model); match scenario.metadata with | `Assoc fields -> @@ -109,9 +148,9 @@ let schema_artifacts_parse () = (List.mem_assoc "$defs" fields) | _ -> Alcotest.fail (path ^ " must contain a JSON object") in - check_schema "../contracts/v3/scenario.schema.json"; - check_schema "../contracts/v3/scenario-stream.schema.json"; - check_schema "../contracts/v3/journal.schema.json" + check_schema "../contracts/v1/scenario.schema.json"; + check_schema "../contracts/v1/scenario-stream.schema.json"; + check_schema "../contracts/v1/journal.schema.json" let timestamp_precision_is_bounded () = List.iter @@ -148,6 +187,234 @@ 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 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 ~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"; + }); + ] + ~market_events:[] ~order_book_events:[] + |> 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 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) @@ -170,16 +437,23 @@ let contract_version_is_required_and_supported () = else (name, value)) fields) in + let unsupported_diagnostic = T.Scenario.of_yojson unsupported |> error in Alcotest.(check string) "unsupported version diagnosed" - "unsupported scenario contract_version \"2\" (expected \"3\")" - (T.Scenario.of_yojson unsupported |> error) + "unsupported scenario contract_version \"2\" (expected one of 1)" + (T.Diagnostic.to_human unsupported_diagnostic); + Alcotest.(check string) + "unsupported version code" "scenario.unsupported_contract" + (T.Diagnostic.code_to_string unsupported_diagnostic.code); + Alcotest.(check (option string)) + "contract path" (Some "$.contract_version") + unsupported_diagnostic.context.json_path let duplicate_fields_are_rejected () = let changed = - map_root (fun fields -> ("initial_cash", `String "0") :: fields) + map_root (fun fields -> ("initial_portfolio", `String "0") :: fields) in - let message = T.Scenario.of_yojson changed |> error in + let message = T.Scenario.of_yojson changed |> diagnostic_message in Alcotest.(check bool) "duplicate field diagnosed" true (String.starts_with ~prefix:"scenario has duplicate JSON fields" message) @@ -241,6 +515,198 @@ let map_field key change = function fields) | _ -> Alcotest.fail "expected object" +let validation_layers_report_precise_context () = + let document = Yojson.Safe.from_string (demo_document ()) in + let instruments = + match document with + | `Assoc fields -> ( + match List.assoc "instruments" fields with + | `List (instrument :: _) -> [ instrument; instrument ] + | _ -> Alcotest.fail "demo instruments must be nonempty") + | _ -> Alcotest.fail "demo must be an object" + in + let batch = + change_field "instruments" (`List instruments) document + |> T.Scenario.of_yojson |> error + in + Alcotest.(check string) + "batch shared validation" "instrument IDs must be unique" batch.message; + Alcotest.(check (option string)) + "batch semantic path" (Some "$.instruments") batch.context.json_path; + let stream_header_payload = + stream_records () |> List.hd |> Yojson.Safe.from_string |> function + | `Assoc fields -> List.assoc "payload" fields + | _ -> Alcotest.fail "stream header must be an object" + in + let stream_header = + change_field "instruments" (`List instruments) stream_header_payload + |> T.Scenario.stream_header_of_yojson ~contract_version:T.Contract.version + |> error + in + Alcotest.(check string) + "stream shares header semantics" batch.message stream_header.message; + Alcotest.(check (option string)) + "stream semantic path" (Some "$.payload.instruments") + stream_header.context.json_path; + let malformed_stream = + stream_records () + |> List.mapi (fun index line -> + if index <> 1 then line + else + Yojson.Safe.from_string line + |> map_field "payload" + (map_field "intents" (function + | `List (`Assoc fields :: remaining) -> + `List + (`Assoc (("unexpected", `Bool true) :: fields) + :: remaining) + | _ -> Alcotest.fail "stream intents must be nonempty")) + |> Yojson.Safe.to_string) + in + with_stream malformed_stream (fun path -> + let diagnostic = T.Replay.run_stream path |> error in + Alcotest.(check (option int)) + "stream record line" (Some 2) diagnostic.context.line; + Alcotest.(check (option string)) + "stream item path" (Some "$.payload.intents[0]") + diagnostic.context.json_path) + +let dense_schedule_document slice_count = + let base = timestamp "2026-02-01T00:00:00Z" in + let slices = + List.init slice_count (fun offset -> + let index = offset + 1 in + let time_offset = offset * 4 in + let start_at = add_seconds base time_offset in + let borrow_observation = + T.Financing.borrow_observation + ~instrument_id:(instrument_id "demo-equity-acme") + ~effective_at:start_at ~available_quantity:(quantity "1000") + ~annual_rate_bps:100 ~recalled:false + |> ok + in + let cash_rate_observation = + T.Financing.cash_rate_observation ~currency:"USD" + ~effective_at:start_at ~credit_rate_bps:100 ~debit_rate_bps:200 + |> ok + in + T.Market_slice.create ~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)) + ~bars: + [ + bar + ~instrument:(instrument_id "demo-equity-acme") + (Int64.of_int index); + ] + ~fx_rates:[ fx_mark () ] + ~corporate_actions:[] ~borrow_observations:[ borrow_observation ] + ~cash_rate_observations:[ cash_rate_observation ] + ~settlement_failures:[] ~lifecycle_events:[] ~market_events:[] + ~order_book_events:[] + |> ok |> T.Codec.market_slice_to_yojson) + in + let schedule = + List.init slice_count (fun offset -> + let sequence = offset + 1 in + `Assoc + [ + ("after_slice_sequence", `String (string_of_int sequence)); + ( "intents", + `List + [ + `Assoc + [ + ("type", `String "emit_metric"); + ("name", `String "dense_schedule"); + ( "value", + `Assoc + [ + ("type", `String "numeric"); + ("value", `String (string_of_int sequence)); + ] ); + ]; + ] ); + ]) + in + map_root (fun fields -> + List.map + (fun (name, value) -> + if String.equal name "slices" then (name, `List slices) + else if String.equal name "schedule" then (name, `List schedule) + else (name, value)) + fields) + +let dense_batch_schedule_validation_scales () = + let slice_count = 20_000 in + let scenario = + dense_schedule_document slice_count |> T.Scenario.of_yojson |> ok + in + Alcotest.(check int) + "all slices retained" slice_count + (List.length scenario.slices); + Alcotest.(check int) + "all schedule entries retained" slice_count + (List.length scenario.schedule) + +let configured_resources_are_bounded () = + let document = Yojson.Safe.from_string (demo_document ()) in + let check_limit expected_path changed = + let diagnostic = T.Scenario.of_yojson changed |> error in + Alcotest.(check string) + "stable resource code" "resource.limit" + (T.Diagnostic.code_to_string diagnostic.code); + Alcotest.(check (option string)) + "resource path" (Some expected_path) diagnostic.context.json_path + in + check_limit "$.max_internal_events" + (change_field "max_internal_events" + (`Int (T.Resource_limits.internal_events + 1)) + document); + let instrument = + match document with + | `Assoc fields -> ( + match List.assoc "instruments" fields with + | `List (value :: _) -> value + | _ -> Alcotest.fail "expected scenario instruments") + | _ -> Alcotest.fail "expected scenario" + in + check_limit "$.instruments" + (change_field "instruments" + (`List + (List.init (T.Resource_limits.catalog_instruments + 1) (fun _ -> + instrument))) + document); + let schedule_item, intent = + match document with + | `Assoc fields -> ( + match List.assoc "schedule" fields with + | `List ((`Assoc item_fields as item) :: _) -> ( + match List.assoc "intents" item_fields with + | `List (intent :: _) -> (item, intent) + | _ -> Alcotest.fail "expected scheduled intents") + | _ -> Alcotest.fail "expected scenario schedule") + | _ -> Alcotest.fail "expected scenario" + in + let oversized_item = + change_field "intents" + (`List + (List.init (T.Resource_limits.intents_per_batch + 1) (fun _ -> intent))) + schedule_item + in + check_limit "$.schedule[0].intents" + (change_field "schedule" (`List [ oversized_item ]) document); + Alcotest.(check bool) + "reducer configuration limit" true + (Result.is_error + (T.Engine.config ~contract_version:T.Contract.version ~risk:(risk ()) + ~venue_calendars:[] + ~execution_model:(T.Execution_model.find "completed_bar_v1" |> ok) + ~execution:(execution ()) ~financing:(financing_policy ()) + ~settlement:(settlement_policy ()) + ~max_internal_events:(T.Resource_limits.internal_events + 1))) + let scenario_with_second_slice_start start_at = map_root (fun fields -> List.map @@ -264,7 +730,7 @@ let market_slice_timeline_is_non_overlapping () = Alcotest.(check string) label "market slice start must not precede previous end" (scenario_with_second_slice_start start_at - |> T.Scenario.of_yojson |> error)) + |> T.Scenario.of_yojson |> diagnostic_message)) [ ("backward start rejected", "2026-01-01T14:30:00Z"); ("overlapping start rejected", "2026-01-02T20:00:00Z"); @@ -291,7 +757,7 @@ let invalid_schedule_sequences_are_rejected () = let missing = update_first_schedule (change_field "after_slice_sequence" (`String "999")) in - let message = T.Scenario.of_yojson missing |> error in + let message = T.Scenario.of_yojson missing |> diagnostic_message in Alcotest.(check string) "missing sequence diagnosed" "scheduled intents refer to missing market slice sequence 999" message; @@ -312,7 +778,29 @@ let invalid_schedule_sequences_are_rejected () = in Alcotest.(check string) "duplicate schedule rejected" "schedule sequences must increase" - (T.Scenario.of_yojson duplicate |> error) + (T.Scenario.of_yojson duplicate |> diagnostic_message); + let late_anchor = + map_root (fun fields -> + List.map + (fun (name, json) -> + if String.equal name "slices" then + match json with + | `List (first :: second :: rest) -> + ( name, + `List + (first + :: change_field "start_at" + (`String "2026-01-02T21:00:01Z") second + :: rest) ) + | _ -> Alcotest.fail "demo must contain at least two slices" + else (name, json)) + fields) + in + Alcotest.(check string) + "late anchor diagnosed" + "scheduled order intent after slice 1 is received after the next \ + executable market slice starts" + (T.Scenario.of_yojson late_anchor |> diagnostic_message) let duplicate_and_incomplete_slice_bars_are_rejected () = let duplicate = @@ -374,7 +862,31 @@ let portfolio_targets_are_total_and_aligned () = map_root (fun fields -> List.map (fun (name, value) -> - if String.equal name "initial_cash" then (name, `String "10000.0") + if String.equal name "initial_portfolio" then + match value with + | `Assoc portfolio_fields -> + let changed = + List.map + (fun (field, field_value) -> + if String.equal field "cash" then + match field_value with + | `List (`Assoc cash_fields :: rest) -> + let cash = + `Assoc + (List.map + (fun (cash_field, cash_value) -> + if String.equal cash_field "amount" then + (cash_field, `String "10000.0") + else (cash_field, cash_value)) + cash_fields) + in + (field, `List (cash :: rest)) + | _ -> (field, field_value) + else (field, field_value)) + portfolio_fields + in + (name, `Assoc changed) + | _ -> (name, value) else (name, value)) fields) in @@ -382,6 +894,92 @@ let portfolio_targets_are_total_and_aligned () = "noncanonical scalar rejected" true (Result.is_error (T.Scenario.of_yojson noncanonical)) +let update_initial_portfolio field change = + map_root (fun fields -> + List.map + (fun (name, value) -> + if String.equal name "initial_portfolio" then + match value with + | `Assoc portfolio_fields -> + ( name, + `Assoc + (List.map + (fun (key, item) -> + if String.equal key field then (key, change item) + else (key, item)) + portfolio_fields) ) + | _ -> (name, value) + else (name, value)) + fields) + +let update_first_object_field field value = function + | `List (`Assoc fields :: rest) -> + `List + (`Assoc + (List.map + (fun (name, current) -> + if String.equal name field then (name, value) + else (name, current)) + fields) + :: rest) + | _ -> Alcotest.fail "expected a nonempty object array" + +let initial_portfolio_validation () = + let signed_cash = + update_initial_portfolio "cash" + (update_first_object_field "amount" (`String "-1")) + in + Alcotest.(check bool) + "signed cash accepted" true + (Result.is_ok (T.Scenario.of_yojson signed_cash)); + let wrong_basis = + update_initial_portfolio "positions" + (update_first_object_field "cost_basis" (`String "-90")) + in + Alcotest.(check bool) + "basis sign rejected" true + (Result.is_error (T.Scenario.of_yojson wrong_basis)); + let off_lot = + update_initial_portfolio "positions" + (update_first_object_field "quantity" (`String "0.0005")) + in + Alcotest.(check bool) + "off-lot holding rejected" true + (Result.is_error (T.Scenario.of_yojson off_lot)); + let missing_mark = update_initial_portfolio "marks" (fun _ -> `List []) in + Alcotest.(check bool) + "missing initial mark rejected" true + (Result.is_error (T.Scenario.of_yojson missing_mark)); + let insufficient_margin = + update_initial_portfolio "cash" + (update_first_object_field "amount" (`String "-100")) + in + Alcotest.(check bool) + "initial margin enforced" true + (Result.is_error (T.Scenario.of_yojson insufficient_margin)) + +let initial_portfolio_is_audited_and_reconciled () = + let result = T.Replay.run ~scenario_sha256:(demo_hash ()) (demo ()) |> ok in + match result.audits with + | _started :: initial :: first_valuation :: _ -> ( + match (initial.event, first_valuation.event) with + | ( T.Audit.Initial_state { portfolio; valuation = initial_valuation }, + T.Audit.Valuation first_valuation ) -> + Alcotest.(check int) "one holding" 1 (List.length portfolio.positions); + Alcotest.check money_testable "initial equity" (money "10100") + initial_valuation.account.equity; + Alcotest.check money_testable "first valuation reconciles" + initial_valuation.account.equity first_valuation.account.equity; + let position = List.hd initial_valuation.account.positions in + Alcotest.check money_testable "native basis" (money "90") + position.cost_basis; + Alcotest.check money_testable "realized attribution" (money "5") + position.realized_pnl; + Alcotest.check money_testable "historical fees" (money "0.75") + position.total_fees + | _ -> Alcotest.fail "expected initial_state followed by valuation") + | _ -> Alcotest.fail "expected initial audit records" + let execution_model_is_required_and_supported () = let change_execution change = map_root (fun fields -> @@ -408,7 +1006,85 @@ let execution_model_is_required_and_supported () = in Alcotest.(check string) "unsupported model diagnosed" "unsupported execution model \"future_model\"" - (T.Scenario.of_yojson unsupported |> error) + (T.Scenario.of_yojson unsupported |> diagnostic_message); + let change_configuration change = + change_execution (map_field "configuration" change) + in + let missing_version = + change_configuration (function + | `Assoc fields -> + `Assoc + (List.filter + (fun (name, _) -> not (String.equal name "version")) + fields) + | _ -> Alcotest.fail "configuration must be an object") + in + Alcotest.(check bool) + "configuration version required" true + (Result.is_error (T.Scenario.of_yojson missing_version)); + let unsupported_version = + change_configuration (change_field "version" (`String "99")) + in + Alcotest.(check string) + "unsupported model/version diagnosed" + "unsupported execution configuration version \"99\" for model \ + \"completed_bar_adverse_touch_v1\"" + (T.Scenario.of_yojson unsupported_version |> diagnostic_message); + let extra_configuration = + change_configuration (function + | `Assoc fields -> `Assoc (("future_parameter", `Int 1) :: fields) + | _ -> Alcotest.fail "configuration must be an object") + in + Alcotest.(check bool) + "model configuration is strict" true + (Result.is_error (T.Scenario.of_yojson extra_configuration)); + let unsupported_spread = + change_configuration + (map_field "spread_model" + (change_field "model" (`String "future_spread"))) + in + Alcotest.(check string) + "spread model is explicit" "unsupported spread model" + (T.Scenario.of_yojson unsupported_spread |> diagnostic_message); + let unsupported_impact = + change_configuration + (map_field "impact_model" + (change_field "model" (`String "future_impact"))) + in + Alcotest.(check string) + "impact model is explicit" "unsupported impact model" + (T.Scenario.of_yojson unsupported_impact |> diagnostic_message); + let invalid_missing_volume = + change_configuration + (map_field "impact_model" + (change_field "missing_volume_policy" (`String "estimate"))) + in + Alcotest.(check string) + "missing-volume policy is explicit" + "missing_volume_policy must be reject or zero_impact" + (T.Scenario.of_yojson invalid_missing_volume |> diagnostic_message); + let zero_impact = + change_configuration + (map_field "impact_model" + (change_field "missing_volume_policy" (`String "zero_impact"))) + in + Alcotest.(check bool) + "zero-impact policy parses" true + (Result.is_ok (T.Scenario.of_yojson zero_impact)); + let invalid_spread_bps = + change_configuration + (map_field "spread_model" (change_field "half_spread_bps" (`Int 10_001))) + in + Alcotest.(check bool) + "spread bound enforced" true + (Result.is_error (T.Scenario.of_yojson invalid_spread_bps)); + let invalid_impact_bps = + change_configuration + (map_field "impact_model" (change_field "coefficient_bps" (`Int 10_001))) + in + Alcotest.(check bool) + "impact bound enforced" true + (Result.is_error (T.Scenario.of_yojson invalid_impact_bps)) let deterministic_replay () = let scenario = demo () in @@ -419,7 +1095,7 @@ let deterministic_replay () = Alcotest.(check (list string)) "byte-identical event encoding" (encode first) (encode second); Alcotest.(check int) - "one valuation per slice" 4 + "initial valuation plus one per slice" 5 (List.length (List.filter (fun audit -> @@ -465,24 +1141,37 @@ let audit_ids_are_deterministic_and_causal () = in Alcotest.(check (list string)) "external slice has no engine cause" [] - (cause_strings (event 7L)); + (cause_strings (event 10L)); Alcotest.(check (list string)) "target order cites slice and target request" - [ "demo-event-000000000002"; "demo-event-000000000003" ] - (cause_strings (event 5L)); - Alcotest.(check (list string)) - "fill cites order creation and executable slice" - [ "demo-event-000000000005"; "demo-event-000000000007" ] + [ "demo-event-000000000004"; "demo-event-000000000006" ] (cause_strings (event 8L)); + Alcotest.(check (list string)) + "price selection cites order creation and executable slice" + [ "demo-event-000000000008"; "demo-event-000000000010" ] + (cause_strings (event 12L)); + Alcotest.(check (list string)) + "fill cites price selection" + [ "demo-event-000000000012" ] + (cause_strings (event 13L)); + let completion = List.hd (List.rev result.audits) in + let terminal_valuation = + result.audits + |> List.filter (fun audit -> + Int64.compare audit.T.Audit.engine_sequence completion.engine_sequence + < 0 + && String.equal (T.Audit.event_name audit.event) "valuation") + |> List.rev |> List.hd + in Alcotest.(check (list string)) "completion cites terminal valuation" - [ "demo-event-000000000019" ] - (cause_strings (event 20L)); - match (event 5L).event with + [ T.Id.Event.to_string terminal_valuation.event_id ] + (cause_strings completion); + match (event 8L).event with | T.Audit.Order_accepted order -> Alcotest.(check string) "order snapshot retains creation event" - (T.Id.Event.to_string (event 5L).event_id) + (T.Id.Event.to_string (event 8L).event_id) (T.Id.Event.to_string order.created_event_id) | _ -> Alcotest.fail "expected accepted order" @@ -496,14 +1185,15 @@ let replay_ends_with_completion_summary () = (match first.event with | T.Audit.Run_started { scenario_sha256 = actual; execution_model } -> Alcotest.(check string) "start hash" hash actual; - Alcotest.(check string) "start model" "completed_bar_v1" execution_model + Alcotest.(check string) + "start model" "completed_bar_adverse_touch_v1" execution_model | _ -> Alcotest.fail "expected run start"); match completion.event with | T.Audit.Run_completed { scenario_sha256 = actual; execution_model; valuation; _ } -> Alcotest.(check string) "completion hash" hash actual; Alcotest.(check string) - "completion model" "completed_bar_v1" execution_model; + "completion model" "completed_bar_adverse_touch_v1" execution_model; Alcotest.check money_testable "summary equity" result.valuation.equity valuation.account.equity | _ -> Alcotest.fail "expected run completion payload" @@ -515,11 +1205,147 @@ let replay_matches_golden_file () = |> fun value -> value ^ "\n" in let expected = - In_channel.with_open_bin "../contracts/v3/fixtures/demo.journal.jsonl" + In_channel.with_open_bin "../contracts/v1/fixtures/demo.journal.jsonl" In_channel.input_all in Alcotest.(check string) "stable audit contract" expected actual +let fill_clipping_fixture_reconciles () = + let document = + In_channel.with_open_bin + "../contracts/v1/fixtures/fill-clipped.scenario.json" In_channel.input_all + in + let scenario = T.Scenario.of_string document |> ok in + let result = + T.Replay.run ~scenario_sha256:(T.Sha256.digest_string document) scenario + |> ok + in + let actual = + result.audits |> List.map T.Codec.audit_to_string |> String.concat "\n" + |> fun value -> value ^ "\n" + in + let expected = + In_channel.with_open_bin + "../contracts/v1/fixtures/fill-clipped.journal.jsonl" In_channel.input_all + in + Alcotest.(check string) "fill clipping audit reconciliation" expected actual + +let quote_trade_replay_is_causal_and_stream_equivalent () = + let document = + In_channel.with_open_bin quote_trade_path In_channel.input_all + in + let scenario = T.Scenario.of_string document |> ok in + let batch = + T.Replay.run ~scenario_sha256:(T.Sha256.digest_string document) scenario + |> ok + in + let batch_journal = + batch.audits |> List.map T.Codec.audit_to_string |> String.concat "\n" + |> fun value -> value ^ "\n" + in + let golden = + In_channel.with_open_bin + "../contracts/v1/fixtures/quote-trade.journal.jsonl" In_channel.input_all + in + Alcotest.(check string) "quote/trade golden journal" golden batch_journal; + let fills = + List.filter_map + (fun (audit : T.Audit.t) -> + match audit.event with + | T.Audit.Fill_applied fill -> Some fill + | _ -> None) + batch.audits + in + Alcotest.(check (list string)) + "only aggressor-qualified trade liquidity fills" + [ "4@99@2026-02-03T14:33:00.000000Z"; "6@100@2026-02-03T14:34:00.000000Z" ] + (List.map + (fun (fill : T.Fill.t) -> + Printf.sprintf "%s@%s@%s" + (T.Scalar.Quantity.to_decimal_string fill.quantity) + (T.Scalar.Price.to_decimal_string fill.price) + (T.Codec.ptime_to_string fill.executed_at)) + fills); + let stream_hash = T.Sha256.digest_file quote_trade_stream_path |> ok in + let expected = + T.Replay.run ~scenario_sha256:stream_hash scenario |> ok |> fun result -> + result.audits |> List.map T.Codec.audit_to_string |> String.concat "\n" + |> fun value -> value ^ "\n" + in + let journal = Filename.temp_file "trading-engine-quote-trade" ".jsonl" in + Sys.remove journal; + Fun.protect + ~finally:(fun () -> + if Sys.file_exists journal then Sys.remove journal; + if Sys.file_exists (journal ^ ".partial") then + Sys.remove (journal ^ ".partial")) + (fun () -> + let streamed = + T.Replay.run_stream ~journal_path:journal quote_trade_stream_path |> ok + in + Alcotest.(check int64) "two streamed slices" 2L streamed.slice_count; + Alcotest.(check string) + "quote/trade stream and batch journals agree" expected + (In_channel.with_open_bin journal In_channel.input_all)) + +let order_book_replay_is_bounded_and_stream_equivalent () = + let document = + In_channel.with_open_bin order_book_path In_channel.input_all + in + let scenario = T.Scenario.of_string document |> ok in + let batch = + T.Replay.run ~scenario_sha256:(T.Sha256.digest_string document) scenario + |> ok + in + let actual = + batch.audits |> List.map T.Codec.audit_to_string |> String.concat "\n" + |> fun value -> value ^ "\n" + in + let golden = + In_channel.with_open_bin "../contracts/v1/fixtures/order-book.journal.jsonl" + In_channel.input_all + in + Alcotest.(check string) "order-book golden journal" golden actual; + let fills = + List.filter_map + (fun (audit : T.Audit.t) -> + match audit.event with + | T.Audit.Fill_applied fill -> Some fill + | _ -> None) + batch.audits + in + Alcotest.(check (list string)) + "queue reduction precedes deterministic partial maker fills" + [ "4@100@2026-02-03T14:35:00.000000Z"; "6@100@2026-02-03T14:36:00.000000Z" ] + (List.map + (fun (fill : T.Fill.t) -> + Printf.sprintf "%s@%s@%s" + (T.Scalar.Quantity.to_decimal_string fill.quantity) + (T.Scalar.Price.to_decimal_string fill.price) + (T.Codec.ptime_to_string fill.executed_at)) + fills); + let stream_hash = T.Sha256.digest_file order_book_stream_path |> ok in + let expected = + T.Replay.run ~scenario_sha256:stream_hash scenario |> ok |> fun result -> + result.audits |> List.map T.Codec.audit_to_string |> String.concat "\n" + |> fun value -> value ^ "\n" + in + let journal = Filename.temp_file "trading-engine-order-book" ".jsonl" in + Sys.remove journal; + Fun.protect + ~finally:(fun () -> + if Sys.file_exists journal then Sys.remove journal; + if Sys.file_exists (journal ^ ".partial") then + Sys.remove (journal ^ ".partial")) + (fun () -> + let streamed = + T.Replay.run_stream ~journal_path:journal order_book_stream_path |> ok + in + Alcotest.(check int64) "two streamed slices" 2L streamed.slice_count; + Alcotest.(check string) + "order-book stream and batch journals agree" expected + (In_channel.with_open_bin journal In_channel.input_all)) + let journal_is_created_exclusively () = let scenario = demo () in let existing = Filename.temp_file "trading-engine" ".jsonl" in @@ -555,7 +1381,7 @@ let journal_finalization_is_exclusive () = (In_channel.with_open_bin path In_channel.input_all); Alcotest.(check bool) "partial preserved" true (Sys.file_exists partial)) -let failed_replay_preserves_partial () = +let invalid_replay_configuration_precedes_artifacts () = let path = Filename.temp_file "trading-engine-failure" ".jsonl" in Sys.remove path; let partial = path ^ ".partial" in @@ -565,11 +1391,11 @@ let failed_replay_preserves_partial () = if Sys.file_exists partial then Sys.remove partial) (fun () -> Alcotest.(check bool) - "invalid hash fails after journal creation" true + "invalid hash is rejected" true (Result.is_error (T.Replay.run ~scenario_sha256:"bad" ~journal_path:path (demo ()))); Alcotest.(check bool) "final absent" false (Sys.file_exists path); - Alcotest.(check bool) "partial retained" true (Sys.file_exists partial)) + Alcotest.(check bool) "partial absent" false (Sys.file_exists partial)) let journal_matches_in_memory_events () = let scenario = demo () in @@ -596,9 +1422,11 @@ let journal_matches_in_memory_events () = let streamed_replay_matches_batch_semantics () = let scenario_sha256 = T.Sha256.digest_file stream_path |> ok in + let expected_result = T.Replay.run ~scenario_sha256 (demo ()) |> ok in let expected = - T.Replay.run ~scenario_sha256 (demo ()) |> ok |> fun result -> - result.audits |> List.map T.Codec.audit_to_string |> String.concat "\n" + expected_result.audits + |> List.map T.Codec.audit_to_string + |> String.concat "\n" |> fun value -> value ^ "\n" in let journal = Filename.temp_file "trading-engine-stream" ".jsonl" in @@ -615,8 +1443,11 @@ let streamed_replay_matches_batch_semantics () = Alcotest.(check int64) "four streamed slices" 4L result.slice_count; Alcotest.(check int64) "two schedule batches" 2L result.schedule_count; Alcotest.(check int) "one instrument" 1 result.instrument_count; - Alcotest.(check int64) "twenty audits" 20L result.audit_count; - Alcotest.check money_testable "same equity" (money "10004.76812") + Alcotest.(check int64) + "same audit count" + (Int64.of_int (List.length expected_result.audits)) + result.audit_count; + Alcotest.check money_testable "same equity" (money "10111.979929") result.valuation.equity; Alcotest.(check string) "stream and batch journals agree" expected @@ -639,7 +1470,8 @@ let streamed_contract_requires_ordered_terminal_records () = Alcotest.(check string) "truncation diagnosed" "scenario_end must terminate the scenario stream" - (T.Replay.run_stream ~journal_path:journal path |> error); + (T.Replay.run_stream ~journal_path:journal path + |> diagnostic_message); Alcotest.(check bool) "invalid stream has no journal" false (Sys.file_exists journal); Alcotest.(check bool) @@ -656,10 +1488,21 @@ let streamed_contract_requires_ordered_terminal_records () = records in with_stream skipped (fun path -> + let diagnostic = T.Replay.run_stream path |> error in Alcotest.(check string) "sequence gap diagnosed" "scenario_sequence must be contiguous and start at one" - (T.Replay.run_stream path |> error)) + (T.Diagnostic.to_human diagnostic); + Alcotest.(check string) + "stream diagnostic code" "scenario_stream.invalid" + (T.Diagnostic.code_to_string diagnostic.code); + Alcotest.(check (option int)) + "record line" (Some 3) diagnostic.context.line; + Alcotest.(check (option int64)) + "observed sequence" (Some 9L) diagnostic.context.sequence; + Alcotest.(check (option string)) + "sequence path" (Some "$.scenario_sequence") + diagnostic.context.json_path) let stream_with_second_slice_start start_at = stream_records () @@ -670,8 +1513,25 @@ let stream_with_second_slice_start start_at = map_field "payload" (change_field "intents" (`List [])) record else if index = 2 then map_field "payload" - (map_field "market_slice" - (change_field "start_at" (`String start_at))) + (map_field "market_slice" (fun market_slice -> + market_slice + |> change_field "start_at" (`String start_at) + |> map_field "borrow_observations" (function + | `List [ observation ] -> + `List + [ + change_field "effective_at" (`String start_at) + observation; + ] + | value -> value) + |> map_field "cash_rate_observations" (function + | `List [ observation ] -> + `List + [ + change_field "effective_at" (`String start_at) + observation; + ] + | value -> value))) record else record in @@ -683,7 +1543,7 @@ let streamed_market_slice_timeline_is_non_overlapping () = with_stream (stream_with_second_slice_start start_at) (fun path -> Alcotest.(check string) label "market slice start must not precede previous end" - (T.Replay.run_stream path |> error))) + (T.Replay.run_stream path |> diagnostic_message))) [ ("backward start rejected", "2026-01-01T14:30:00Z"); ("overlapping start rejected", "2026-01-02T20:00:00Z"); @@ -711,7 +1571,64 @@ let streamed_intents_are_causal_before_execution () = "lookahead intent rejected" "scheduled order intent after slice 1 is received after the next \ executable market slice starts" - (T.Replay.run_stream path |> error)) + (T.Replay.run_stream path |> diagnostic_message)) + +let scenario_stream_records_are_bounded () = + let records = stream_records () in + let maximum = + List.fold_left + (fun current line -> Int.max current (String.length line)) + 0 records + in + with_stream records (fun path -> + Alcotest.(check int64) + "record at exact limit accepted" 4L + (fold_stream_with_limit maximum path |> ok)); + let longest_line, longest_index = + records + |> List.mapi (fun index line -> (line, index + 1)) + |> List.fold_left + (fun ((current, _) as selected) ((candidate, _) as next) -> + if String.length candidate > String.length current then next + else selected) + ("", 0) + in + with_stream records (fun path -> + let diagnostic = fold_stream_with_limit (maximum - 1) path |> error in + Alcotest.(check string) + "oversized record code" "resource.limit" + (T.Diagnostic.code_to_string diagnostic.code); + Alcotest.(check (option int)) + "oversized record line" (Some longest_index) diagnostic.context.line; + Alcotest.(check string) + "observed and allowed bytes" + (Printf.sprintf "scenario stream record is %d bytes; limit is %d bytes" + (String.length longest_line) + (maximum - 1)) + diagnostic.message); + with_stream_document (String.concat "\n" records) (fun path -> + Alcotest.(check int64) + "newline-free terminal record accepted" 4L + (fold_stream_with_limit maximum path |> ok)); + let truncated = List.hd records ^ "\n{\"contract_version\"" in + with_stream_document truncated (fun path -> + let diagnostic = fold_stream_with_limit maximum path |> error in + Alcotest.(check string) + "truncated record remains a JSON error" "scenario.invalid_json" + (T.Diagnostic.code_to_string diagnostic.code); + Alcotest.(check (option int)) + "truncated record line" (Some 2) diagnostic.context.line); + let small_limit = 32 in + let newline_free = String.make (small_limit + 7) 'x' in + with_stream_document newline_free (fun path -> + let diagnostic = fold_stream_with_limit small_limit path |> error in + Alcotest.(check string) + "newline-free oversized code" "resource.limit" + (T.Diagnostic.code_to_string diagnostic.code); + Alcotest.(check string) + "newline-free observed bytes" + "scenario stream record is 39 bytes; limit is 32 bytes" + diagnostic.message) let large_stream_replay_does_not_retain_audit_history () = let slice_count = 10_000 in @@ -725,13 +1642,15 @@ let large_stream_replay_does_not_retain_audit_history () = "all slices consumed" (Int64.of_int slice_count) result.slice_count; Alcotest.(check int64) "events counted without an audit list" - (Int64.of_int ((2 * slice_count) + 2)) + (Int64.of_int ((2 * slice_count) + 4)) result.audit_count; Alcotest.(check int) "no orders accumulated" 0 (List.length result.orders)) let tests = [ Alcotest.test_case "demo contract parses" `Quick demo_contract_parses; + Alcotest.test_case "distributions and lifecycle parse" `Quick + 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; @@ -743,14 +1662,24 @@ let tests = duplicate_fields_are_rejected; Alcotest.test_case "metadata validation is recursive" `Quick recursive_metadata_validation; + Alcotest.test_case "validation layers report precise context" `Quick + validation_layers_report_precise_context; + Alcotest.test_case "configured resources are bounded" `Quick + configured_resources_are_bounded; Alcotest.test_case "invalid schedule sequences rejected" `Quick invalid_schedule_sequences_are_rejected; + Alcotest.test_case "dense batch schedule validation" `Slow + dense_batch_schedule_validation_scales; Alcotest.test_case "duplicate slice bars rejected" `Quick duplicate_and_incomplete_slice_bars_are_rejected; Alcotest.test_case "market slice timeline is non-overlapping" `Quick market_slice_timeline_is_non_overlapping; Alcotest.test_case "portfolio target validation" `Quick portfolio_targets_are_total_and_aligned; + Alcotest.test_case "initial portfolio validation" `Quick + initial_portfolio_validation; + Alcotest.test_case "initial portfolio audit reconciliation" `Quick + initial_portfolio_is_audited_and_reconciled; Alcotest.test_case "execution model required and supported" `Quick execution_model_is_required_and_supported; Alcotest.test_case "deterministic replay" `Quick deterministic_replay; @@ -760,12 +1689,18 @@ let tests = replay_ends_with_completion_summary; Alcotest.test_case "replay matches golden file" `Quick replay_matches_golden_file; + Alcotest.test_case "fill clipping fixture reconciles" `Quick + fill_clipping_fixture_reconciles; + Alcotest.test_case "quote/trade replay is causal and stream equivalent" + `Quick quote_trade_replay_is_causal_and_stream_equivalent; + Alcotest.test_case "order-book replay is bounded and stream equivalent" + `Quick order_book_replay_is_bounded_and_stream_equivalent; Alcotest.test_case "exclusive journal creation" `Quick journal_is_created_exclusively; Alcotest.test_case "exclusive journal finalization" `Quick journal_finalization_is_exclusive; - Alcotest.test_case "failed replay preserves partial" `Quick - failed_replay_preserves_partial; + Alcotest.test_case "configuration precedes artifacts" `Quick + invalid_replay_configuration_precedes_artifacts; Alcotest.test_case "journal matches events" `Quick journal_matches_in_memory_events; Alcotest.test_case "stream replay matches batch semantics" `Quick @@ -776,6 +1711,8 @@ let tests = `Quick streamed_market_slice_timeline_is_non_overlapping; Alcotest.test_case "streamed intents are causal" `Quick streamed_intents_are_causal_before_execution; + Alcotest.test_case "scenario stream records are bounded" `Quick + scenario_stream_records_are_bounded; Alcotest.test_case "large stream avoids retained audit history" `Slow large_stream_replay_does_not_retain_audit_history; ] diff --git a/test/test_settlement.ml b/test/test_settlement.ml new file mode 100644 index 0000000..3b3b4c1 --- /dev/null +++ b/test/test_settlement.ml @@ -0,0 +1,378 @@ +open Test_support +module T = Trading_engine +module Runner = T.Engine.Make (T.Scripted_strategy) + +let settlement_policy ?(cash_buying_power = T.Settlement.Total_cash) + ?(position_availability = T.Settlement.Total_positions) ?(lag = 1) () = + let calendar = + T.Settlement.calendar ~calendar_id:"test-settlement" ~version:"1" + ~business_dates:[ "2026-01-02"; "2026-01-03"; "2026-01-04"; "2026-01-05" ] + |> ok + in + let rule = + T.Settlement.rule + ~instrument_id:(instrument_id "test-equity") + ~calendar_id:"test-settlement" ~lag_business_days:lag + |> ok + in + T.Settlement.policy ~cash_buying_power ~position_availability + ~calendars:[ calendar ] ~rules:[ rule ] + |> ok + +let buy_fill () = + let order = request ~quantity_value:"2" () |> accepted_order in + fill ~quantity_value:"2" ~fee_value:"1" + ~executed_at:(timestamp "2026-01-03T21:00:00Z") + order + +let calendar_and_trade_date_accounting () = + let policy = settlement_policy () in + let fill = buy_fill () in + let instruction = T.Settlement.instruction policy fill |> ok in + Alcotest.(check string) "trade date" "2026-01-03" instruction.trade_date; + Alcotest.(check string) "due date" "2026-01-04" instruction.due_date; + Alcotest.check money_testable "cash movement" (money "-201") + instruction.cash_movement; + Alcotest.check quantity_testable "position movement" (quantity "2") + instruction.position_movement; + let account = test_account ~initial_cash:[ ("USD", money "1000") ] () in + let account = T.Account.apply_unsettled_fill account fill |> ok in + Alcotest.check money_testable "economic cash" (money "799") + (account_cash account); + Alcotest.check money_testable "settled cash unchanged" (money "1000") + (T.Account.settled_cash account "USD" |> Option.get); + Alcotest.check quantity_testable "economic position" (quantity "2") + (T.Account.position_quantity account (instrument_id "test-equity")); + Alcotest.check quantity_testable "settled position unchanged" (quantity "0") + (T.Account.settled_position_quantity account (instrument_id "test-equity")); + let valuation = + account_value account ~marks:[ (instrument_id "test-equity", price "100") ] + in + Alcotest.check money_testable "unsettled cash valuation" (money "-201") + valuation.unsettled_cash; + Alcotest.check quantity_testable "unsettled position valuation" (quantity "2") + (List.hd valuation.positions).unsettled_quantity; + let account = T.Account.apply_settlement account instruction |> ok in + Alcotest.check money_testable "settled cash reconciled" (money "799") + (T.Account.settled_cash account "USD" |> Option.get); + Alcotest.check quantity_testable "settled position reconciled" (quantity "2") + (T.Account.settled_position_quantity account (instrument_id "test-equity")) + +let slice ?(settlement_failures = []) sequence = + let date = match sequence with 1L -> "02" | 2L -> "03" | _ -> "04" in + T.Market_slice.create ~slice_sequence:sequence + ~start_at:(timestamp ("2026-01-" ^ date ^ "T14:30:00Z")) + ~end_at:(timestamp ("2026-01-" ^ date ^ "T21:00:00Z")) + ~available_at:(timestamp ("2026-01-" ^ date ^ "T21:00:01Z")) + ~received_at:(timestamp ("2026-01-" ^ date ^ "T21:00:02Z")) + ~bars:[ bar sequence ] + ~fx_rates:[ fx_mark () ] + ~corporate_actions:[] ~borrow_observations:[] ~cash_rate_observations:[] + ~settlement_failures ~lifecycle_events:[] ~market_events:[] + ~order_book_events:[] + |> ok + +let runner ?(initial_cash = "1000") ?schedule policy run = + let config = + T.Engine.config ~contract_version:"1" ~risk:(risk ()) ~venue_calendars:[] + ~execution_model:(T.Execution_model.find "completed_bar_v1" |> ok) + ~execution:(execution ()) ~financing:(financing_policy ()) + ~settlement:policy ~max_internal_events:1000 + |> ok + in + let schedule = + Option.value schedule + ~default: + [ + ( 1L, + [ + T.Strategy.Target_quantities + [ + { + instrument_id = instrument_id "test-equity"; + quantity = quantity "2"; + }; + ]; + ] ); + ] + in + let strategy_state = T.Scripted_strategy.create schedule |> ok in + Runner.create ~run_id:(run_id run) ~scenario_sha256 ~config + ~initial_portfolio: + (initial_portfolio ~cash:[ ("USD", money initial_cash) ] ()) + ~strategy_state + |> ok + +let find_instruction events = + List.find_map + (fun (event : T.Audit.t) -> + match event.event with + | T.Audit.Settlement_instruction_created instruction -> Some instruction + | _ -> None) + events + |> Option.get + +let engine_settles_due_instruction () = + let state, _ = + Runner.process_slice (runner (settlement_policy ()) "settle") (slice 1L) + |> ok + in + let state, events = Runner.process_slice state (slice 2L) |> ok in + let instruction = find_instruction events in + Alcotest.(check string) + "pending instruction" "pending" + (T.Settlement.status_to_string instruction.status); + Alcotest.check quantity_testable "trade-date position" (quantity "2") + (T.Account.position_quantity (Runner.account state) + (instrument_id "test-equity")); + Alcotest.check quantity_testable "not yet settled" (quantity "0") + (T.Account.settled_position_quantity (Runner.account state) + (instrument_id "test-equity")); + let state, events = Runner.process_slice state (slice 3L) |> ok in + Alcotest.(check bool) + "completion event" true + (List.exists + (fun (event : T.Audit.t) -> + match event.event with + | T.Audit.Settlement_completed _ -> true + | _ -> false) + events); + Alcotest.check quantity_testable "settled position" (quantity "2") + (T.Account.settled_position_quantity (Runner.account state) + (instrument_id "test-equity")) + +let engine_records_settlement_failure () = + let state, _ = + Runner.process_slice (runner (settlement_policy ()) "failure") (slice 1L) + |> ok + in + let state, events = Runner.process_slice state (slice 2L) |> ok in + let instruction = find_instruction events in + let failure = + T.Settlement.failure ~instruction_id:instruction.instruction_id + ~reason:"counterparty default" + |> ok + in + let state, events = + match + Runner.process_slice state (slice ~settlement_failures:[ failure ] 3L) + with + | Ok value -> value + | Error message -> Alcotest.fail message + in + Alcotest.(check bool) + "failure event" true + (List.exists + (fun (event : T.Audit.t) -> + match event.event with + | T.Audit.Settlement_failed _ -> true + | _ -> false) + events); + Alcotest.check quantity_testable "failed position remains unsettled" + (quantity "0") + (T.Account.settled_position_quantity (Runner.account state) + (instrument_id "test-equity")) + +let constructors_reject_ambiguous_inputs () = + let invalid_calendar ?(id = "calendar") ?(version = "1") dates = + T.Settlement.calendar ~calendar_id:id ~version ~business_dates:dates + in + List.iter + (fun result -> + Alcotest.(check bool) "invalid calendar" true (Result.is_error result)) + [ + invalid_calendar ~id:"" [ "2026-01-02" ]; + invalid_calendar ~version:"2" [ "2026-01-02" ]; + invalid_calendar []; + invalid_calendar [ "2026/01/02" ]; + invalid_calendar [ "2026-01-03"; "2026-01-02" ]; + ]; + let id = instrument_id "test-equity" in + Alcotest.(check bool) + "empty rule calendar" true + (Result.is_error + (T.Settlement.rule ~instrument_id:id ~calendar_id:"" ~lag_business_days:1)); + Alcotest.(check bool) + "invalid lag" true + (Result.is_error + (T.Settlement.rule ~instrument_id:id ~calendar_id:"calendar" + ~lag_business_days:31)); + Alcotest.(check bool) + "negative lag" true + (Result.is_error + (T.Settlement.rule ~instrument_id:id ~calendar_id:"calendar" + ~lag_business_days:(-1))); + let calendar = invalid_calendar [ "2026-01-03" ] |> ok in + let rule = + T.Settlement.rule ~instrument_id:id ~calendar_id:"calendar" + ~lag_business_days:1 + |> ok + in + let make_policy calendars rules = + T.Settlement.policy ~cash_buying_power:T.Settlement.Total_cash + ~position_availability:T.Settlement.Total_positions ~calendars ~rules + in + List.iter + (fun result -> + Alcotest.(check bool) "invalid policy" true (Result.is_error result)) + [ + make_policy [] [ rule ]; + make_policy [ calendar ] []; + make_policy [ calendar; calendar ] [ rule ]; + make_policy [ calendar ] [ rule; rule ]; + make_policy [ calendar ] + [ + T.Settlement.rule ~instrument_id:id ~calendar_id:"unknown" + ~lag_business_days:1 + |> ok; + ]; + ]; + let policy = make_policy [ calendar ] [ rule ] |> ok in + Alcotest.(check bool) + "calendar misses due date" true + (Result.is_error (T.Settlement.instruction policy (buy_fill ()))); + let absent_trade_calendar = + invalid_calendar [ "2026-01-02"; "2026-01-04" ] |> ok + in + let absent_trade_policy = + make_policy [ absent_trade_calendar ] [ rule ] |> ok + in + Alcotest.(check bool) + "calendar misses trade date" true + (Result.is_error + (T.Settlement.instruction absent_trade_policy (buy_fill ()))); + let other_order = + request ~instrument:(instrument_id "other") () |> accepted_order + in + Alcotest.(check bool) + "missing instrument rule" true + (Result.is_error (T.Settlement.instruction policy (fill other_order))); + Alcotest.(check bool) + "failure instruction required" true + (Result.is_error (T.Settlement.failure ~instruction_id:"" ~reason:"reason")); + Alcotest.(check bool) + "failure reason trimmed" true + (Result.is_error + (T.Settlement.failure ~instruction_id:"instruction" ~reason:" bad ")); + let instruction = + T.Settlement.instruction (settlement_policy ()) (buy_fill ()) |> ok + in + let settled = + T.Settlement.settle instruction + ~settled_at:(timestamp "2026-01-04T21:00:00Z") + |> ok + in + Alcotest.(check string) + "settled status" "settled" + (T.Settlement.status_to_string settled.status); + Alcotest.(check bool) + "settled instruction terminal" true + (Result.is_error + (T.Settlement.settle settled + ~settled_at:(timestamp "2026-01-05T21:00:00Z"))); + let failed = + T.Settlement.fail instruction + ~failed_at:(timestamp "2026-01-04T21:00:00Z") + ~reason:"default" + |> ok + in + Alcotest.(check string) + "failed status" "failed" + (T.Settlement.status_to_string failed.status); + Alcotest.(check bool) + "failed instruction terminal" true + (Result.is_error + (T.Settlement.fail failed + ~failed_at:(timestamp "2026-01-05T21:00:00Z") + ~reason:"again")); + Alcotest.(check bool) + "failed instruction cannot settle" true + (Result.is_error + (T.Settlement.settle failed + ~settled_at:(timestamp "2026-01-05T21:00:00Z"))); + Alcotest.(check bool) + "settled instruction cannot fail" true + (Result.is_error + (T.Settlement.fail settled + ~failed_at:(timestamp "2026-01-05T21:00:00Z") + ~reason:"again")); + Alcotest.(check string) + "total buying power name" "total_cash" + (T.Settlement.cash_buying_power_to_string T.Settlement.Total_cash); + Alcotest.(check string) + "settled buying power name" "settled_cash" + (T.Settlement.cash_buying_power_to_string T.Settlement.Settled_cash); + Alcotest.(check string) + "total position name" "total_positions" + (T.Settlement.position_availability_to_string T.Settlement.Total_positions); + Alcotest.(check string) + "settled position name" "settled_positions" + (T.Settlement.position_availability_to_string T.Settlement.Settled_positions) + +let settlement_limits_are_explicit () = + let unknown_failure = + T.Settlement.failure ~instruction_id:"unknown-settlement" ~reason:"default" + |> ok + in + let state = runner (settlement_policy ()) "unknown-failure" in + let state, _ = Runner.process_slice state (slice 1L) |> ok in + Alcotest.(check bool) + "unknown settlement failure rejected" true + (Result.is_error + (Runner.process_slice state + (slice ~settlement_failures:[ unknown_failure ] 2L))); + let state = runner ~initial_cash:"150" (settlement_policy ()) "cash-limit" in + let state, _ = Runner.process_slice state (slice 1L) |> ok in + let _, events = Runner.process_slice state (slice 2L) |> ok in + Alcotest.(check bool) + "cash buying-power limit" true + (List.exists + (fun (event : T.Audit.t) -> + match event.event with + | T.Audit.Fill_clipped + { limit = T.Risk.Settlement_cash_buying_power _; _ } -> + true + | _ -> false) + events); + let target value = + T.Strategy.Target_quantities + [ + { + instrument_id = instrument_id "test-equity"; + quantity = quantity value; + }; + ] + in + let schedule = [ (1L, [ target "2" ]); (2L, [ target "0" ]) ] in + let policy = + settlement_policy ~position_availability:T.Settlement.Settled_positions + ~lag:2 () + in + let state = runner ~schedule policy "position-limit" in + let state, _ = Runner.process_slice state (slice 1L) |> ok in + let state, _ = Runner.process_slice state (slice 2L) |> ok in + let _, events = Runner.process_slice state (slice 3L) |> ok in + Alcotest.(check bool) + "settled-position limit" true + (List.exists + (fun (event : T.Audit.t) -> + match event.event with + | T.Audit.Fill_clipped + { limit = T.Risk.Settlement_position_availability _; _ } -> + true + | _ -> false) + events) + +let tests = + [ + Alcotest.test_case "calendar and trade-date accounting" `Quick + calendar_and_trade_date_accounting; + Alcotest.test_case "engine settles due instruction" `Quick + engine_settles_due_instruction; + Alcotest.test_case "engine records settlement failure" `Quick + engine_records_settlement_failure; + Alcotest.test_case "constructors reject ambiguous inputs" `Quick + constructors_reject_ambiguous_inputs; + Alcotest.test_case "settlement limits are explicit" `Quick + settlement_limits_are_explicit; + ] diff --git a/test/test_strategy_protocol.ml b/test/test_strategy_protocol.ml index 4fa05c4..903bdec 100644 --- a/test/test_strategy_protocol.ml +++ b/test/test_strategy_protocol.ml @@ -3,6 +3,18 @@ module T = Trading_engine let initialization () = let instrument = instrument () in + let component = + T.Fee_schedule.create_component ~name:"broker" ~currency:"USD" + ~basis:(T.Fee_schedule.Fixed (money "0.25")) + ~rounding:T.Fee_schedule.Up ~applicability:T.Fee_schedule.Any + |> ok + in + let fee_schedule = + T.Fee_schedule.create ~schedule_id:"test-fees-v1" + ~instrument_id:instrument.id ~settlement_currency:"USD" ~minimum:None + ~maximum:None ~components:[ component ] + |> ok + in T.Strategy_protocol. { scenario_contract_version = T.Contract.version; @@ -10,11 +22,17 @@ let initialization () = metadata = `Assoc [ ("experiment", `String "demo") ]; run_id = run_id "test-run"; base_currency = "USD"; - initial_cash = [ ("USD", money "10000") ]; + initial_portfolio = initial_portfolio (); instruments = [ instrument ]; + venue_calendars = []; risk = risk ~instruments:[ instrument ] (); execution_model = T.Execution_model.find "completed_bar_v1" |> ok; - execution = execution (); + execution = + T.Execution.create ~participation_bps:10_000 + ~fee_schedules:[ fee_schedule ] + |> ok; + financing = financing_policy (); + settlement = settlement_policy (); } let field name = function @@ -26,7 +44,7 @@ let initialize_message_is_complete () = T.Strategy_protocol.initialize_message ~sequence:1L (initialization ()) in Alcotest.(check string) - "protocol version" "3" + "protocol version" "1" (match field "strategy_protocol_version" message with | `String value -> value | _ -> Alcotest.fail "expected version string"); @@ -47,6 +65,88 @@ let initialize_message_is_complete () = | `List values -> List.length values | _ -> Alcotest.fail "expected instruments") +let initialize_message_includes_calendars () = + let phase = + T.Venue_calendar.create_phase ~kind:T.Venue_calendar.Regular + ~opens_at:(timestamp "2026-01-02T14:30:00Z") + ~closes_at:(timestamp "2026-01-02T21:00:00Z") + |> ok + in + let session = + T.Venue_calendar.create_session ~session_date:"2026-01-02" + ~kind:T.Venue_calendar.Regular_session ~phases:[ phase ] + |> ok + in + let holiday = + T.Venue_calendar.create_session ~session_date:"2026-01-03" + ~kind:T.Venue_calendar.Holiday ~phases:[] + |> ok + in + let calendar = + T.Venue_calendar.create + ~id:(T.Id.Venue_calendar.of_string_exn "xnas-test") + ~version:"1" + ~venue_id:(T.Id.Venue.of_string_exn "XNAS") + ~instrument_ids:[ instrument_id "test-equity" ] + ~sessions:[ session; holiday ] + |> ok + in + let message = + T.Strategy_protocol.initialize_message ~sequence:1L + { (initialization ()) with venue_calendars = [ calendar ] } + in + match field "payload" message |> field "venue_calendars" with + | `List [ calendar ] -> + Alcotest.(check string) + "calendar identity" "xnas-test" + (match field "calendar_id" calendar with + | `String value -> value + | _ -> Alcotest.fail "expected calendar ID") + | _ -> Alcotest.fail "expected one serialized venue calendar" + +let conservative_initialize_message_encodes_cost_models () = + let check model_name policy expected_policy = + let base = initialization () in + let execution = + T.Execution.create_conservative ~participation_bps:7500 + ~fee_schedules:(T.Execution.fee_schedules base.execution) + ~half_spread_bps:7 ~impact_coefficient_bps:23 + ~missing_volume_policy:policy + |> ok + in + let execution_model = T.Execution_model.find model_name |> ok in + let message = + T.Strategy_protocol.initialize_message ~sequence:1L + { base with execution_model; execution } + in + let encoded = field "payload" message |> field "execution" in + Alcotest.(check string) + "conservative model name" model_name + (match field "model" encoded with + | `String value -> value + | _ -> Alcotest.fail "expected execution model"); + let configuration = field "configuration" encoded in + Alcotest.(check int) + "half spread" 7 + (match field "spread_model" configuration |> field "half_spread_bps" with + | `Int value -> value + | _ -> Alcotest.fail "expected half spread"); + Alcotest.(check int) + "impact coefficient" 23 + (match field "impact_model" configuration |> field "coefficient_bps" with + | `Int value -> value + | _ -> Alcotest.fail "expected impact coefficient"); + Alcotest.(check string) + "missing volume policy" expected_policy + (match + field "impact_model" configuration |> field "missing_volume_policy" + with + | `String value -> value + | _ -> Alcotest.fail "expected missing-volume policy") + in + check "completed_bar_next_open_v1" T.Execution.Reject_missing_volume "reject"; + check "completed_bar_adverse_touch_v1" T.Execution.Zero_impact "zero_impact" + let event_message_contains_complete_context () = let account = test_account () in let slice = market_slice 1L in @@ -54,8 +154,8 @@ let event_message_contains_complete_context () = account_value account ~marks:[ (instrument_id "test-equity", price "105") ] in let context = - T.Strategy.context ~now:slice.received_at ~valuation ~working_orders:[] - ~latest_bars:slice.bars + T.Strategy.context ~now:slice.received_at ~valuation ~group_exposures:[] + ~working_orders:[] ~latest_bars:slice.bars |> ok in let message = @@ -106,8 +206,8 @@ let nonpositive_equity_omits_weights () = account_value account ~marks:[ (instrument_id "test-equity", price "105") ] in let context = - T.Strategy.context ~now:slice.received_at ~valuation ~working_orders:[] - ~latest_bars:slice.bars + T.Strategy.context ~now:slice.received_at ~valuation ~group_exposures:[] + ~working_orders:[] ~latest_bars:slice.bars |> ok in let message = @@ -134,7 +234,7 @@ let nonpositive_equity_omits_weights () = let response message_type payload = `Assoc [ - ("strategy_protocol_version", `String "3"); + ("strategy_protocol_version", `String "1"); ("strategy_sequence", `String "3"); ("message_type", `String message_type); ("payload", payload); @@ -170,7 +270,31 @@ let responses_are_strict_and_typed () = [ ("type", `String "emit_metric"); ("name", `String "signal"); - ("value", `String "0.5"); + ( "value", + `Assoc + [ + ("type", `String "numeric"); ("value", `String "0.5"); + ] ); + ]; + `Assoc + [ + ("type", `String "emit_metric"); + ("name", `String "regime"); + ( "value", + `Assoc + [ + ("type", `String "string"); + ("value", `String "risk-on"); + ] ); + ]; + `Assoc + [ + ("type", `String "emit_metric"); + ("name", `String "healthy"); + ( "value", + `Assoc + [ ("type", `String "boolean"); ("value", `Bool true) ] + ); ]; ] ); ]) @@ -178,12 +302,31 @@ let responses_are_strict_and_typed () = (match T.Strategy_protocol.response_of_yojson ~expected_sequence:3L intents |> ok with - | T.Strategy_protocol.Intents [ T.Strategy.Emit_metric { name; value } ] -> - Alcotest.(check string) "metric name" "signal" name; - Alcotest.(check string) "metric value" "0.5" value + | T.Strategy_protocol.Intents + [ + T.Strategy.Emit_metric metric; + T.Strategy.Emit_metric string_metric; + T.Strategy.Emit_metric boolean_metric; + ] -> ( + Alcotest.(check string) "metric name" "signal" metric.name; + (match metric.value with + | T.Metric.Numeric value -> + Alcotest.(check string) + "metric value" "0.5" + (T.Metric.numeric_to_string value) + | _ -> Alcotest.fail "expected numeric metric value"); + (match string_metric.value with + | T.Metric.String value -> + Alcotest.(check string) "string metric value" "risk-on" value + | _ -> Alcotest.fail "expected string metric value"); + match boolean_metric.value with + | T.Metric.Boolean value -> + Alcotest.(check bool) "boolean metric value" true value + | _ -> Alcotest.fail "expected boolean metric value") | _ -> Alcotest.fail "expected metric intent"); let wrong_sequence = - T.Strategy_protocol.response_of_yojson ~expected_sequence:4L ready |> error + T.Strategy_protocol.response_of_yojson ~expected_sequence:4L ready + |> diagnostic_message in Alcotest.(check bool) "wrong sequence rejected" true @@ -191,8 +334,8 @@ let responses_are_strict_and_typed () = let duplicate = `Assoc [ - ("strategy_protocol_version", `String "3"); - ("strategy_protocol_version", `String "3"); + ("strategy_protocol_version", `String "1"); + ("strategy_protocol_version", `String "1"); ("strategy_sequence", `String "3"); ("message_type", `String "stopped"); ("payload", `Assoc []); @@ -202,24 +345,25 @@ let responses_are_strict_and_typed () = "duplicate field rejected" "strategy response must not contain duplicate fields" (T.Strategy_protocol.response_of_yojson ~expected_sequence:3L duplicate - |> error); + |> diagnostic_message); let wrong_version = `Assoc [ - ("strategy_protocol_version", `String "1"); + ("strategy_protocol_version", `String "unsupported"); ("strategy_sequence", `String "3"); ("message_type", `String "stopped"); ("payload", `Assoc []); ] in Alcotest.(check string) - "wrong version rejected" "unsupported strategy protocol version: 1" + "wrong version rejected" + "unsupported strategy protocol version: unsupported" (T.Strategy_protocol.response_of_yojson ~expected_sequence:3L wrong_version - |> error); + |> diagnostic_message); let unknown_field = `Assoc [ - ("strategy_protocol_version", `String "3"); + ("strategy_protocol_version", `String "unsupported"); ("strategy_sequence", `String "3"); ("message_type", `String "stopped"); ("payload", `Assoc []); @@ -229,18 +373,68 @@ let responses_are_strict_and_typed () = Alcotest.(check string) "unknown field rejected" "strategy response has unknown or missing fields" (T.Strategy_protocol.response_of_yojson ~expected_sequence:3L unknown_field - |> error); + |> diagnostic_message); Alcotest.(check bool) "malformed JSON rejected" true (T.Strategy_protocol.response_of_string ~expected_sequence:3L "{" - |> error + |> diagnostic_message |> String.starts_with ~prefix:"invalid strategy response JSON:"); + let oversized = + T.Strategy_protocol.response_of_string ~expected_sequence:3L + (String.make (T.Strategy_protocol.max_message_bytes + 1) 'x') + |> error + in + Alcotest.(check string) + "oversized response code" "resource.limit" + (T.Diagnostic.code_to_string oversized.code); Alcotest.(check string) "oversized response rejected" - "strategy response exceeds the maximum message size" - (T.Strategy_protocol.response_of_string ~expected_sequence:3L - (String.make (T.Strategy_protocol.max_message_bytes + 1) 'x') - |> error) + (Printf.sprintf "strategy message is %d bytes; limit is %d bytes" + (T.Strategy_protocol.max_message_bytes + 1) + T.Strategy_protocol.max_message_bytes) + oversized.message; + let oversized_intents = + response "intents" + (`Assoc + [ + ( "intents", + `List + (List.init (T.Resource_limits.intents_per_batch + 1) (fun _ -> + `Null)) ); + ]) + |> T.Strategy_protocol.response_of_yojson ~expected_sequence:3L + |> error + in + Alcotest.(check string) + "intent limit code" "resource.limit" + (T.Diagnostic.code_to_string oversized_intents.code); + Alcotest.(check (option string)) + "intent limit path" (Some "$.payload.intents") + oversized_intents.context.json_path + +let strategy_configuration_is_validated_before_use () = + let initialization = + { + (initialization ()) with + metadata = + `Assoc + [ + ( "padding", + `String (String.make T.Resource_limits.strategy_message_bytes 'x') + ); + ]; + } + in + let diagnostic = + T.Strategy_process.validate_configuration ~command:[ "unused" ] ~timeout:1.0 + ~initialization + |> error + in + Alcotest.(check string) + "initialization limit code" "resource.limit" + (T.Diagnostic.code_to_string diagnostic.code); + Alcotest.(check (option int64)) + "initialization sequence" (Some 1L) diagnostic.context.sequence let transcript_records_direction_and_sequence () = let message = T.Strategy_protocol.shutdown_message ~sequence:9L in @@ -259,16 +453,118 @@ let transcript_records_direction_and_sequence () = | `String value -> value | _ -> Alcotest.fail "expected transcript direction") +let absent_temp_path suffix = + let path = Filename.temp_file "trading-engine-process" suffix in + Sys.remove path; + path + +let remove_if_exists path = if Sys.file_exists path then Sys.remove path + +let grandchild_pid pid_path = + In_channel.with_open_text pid_path (fun channel -> + In_channel.input_all channel |> String.trim |> int_of_string) + +let check_process_gone pid = + match Unix.kill pid 0 with + | () -> Alcotest.fail "grandchild process survived session cleanup" + | exception Unix.Unix_error (Unix.ESRCH, _, _) -> () + +let with_process_tree_paths test = + let pid_path = absent_temp_path ".pid" in + let transcript_path = absent_temp_path ".jsonl" in + Fun.protect + ~finally:(fun () -> + remove_if_exists pid_path; + remove_if_exists transcript_path; + remove_if_exists (transcript_path ^ ".partial")) + (fun () -> test pid_path transcript_path) + +let invalid_configuration_creates_no_transcript_or_process () = + with_process_tree_paths @@ fun _ transcript_path -> + let initialization = + { + (initialization ()) with + metadata = + `Assoc + [ + ( "padding", + `String (String.make T.Resource_limits.strategy_message_bytes 'x') + ); + ]; + } + in + let diagnostic = + Eio_main.run @@ fun env -> + T.Strategy_process.with_session ~env + ~command:[ "/definitely/missing/strategy" ] + ~timeout:1.0 ~transcript_path ~initialization (fun _ -> Ok ()) + |> error + in + Alcotest.(check string) + "configuration fails before spawn" "resource.limit" + (T.Diagnostic.code_to_string diagnostic.code); + Alcotest.(check bool) "no transcript" false (Sys.file_exists transcript_path); + Alcotest.(check bool) + "no partial transcript" false + (Sys.file_exists (transcript_path ^ ".partial")) + +let callback_exception_reaps_process_tree () = + with_process_tree_paths @@ fun pid_path transcript_path -> + let result = + Eio_main.run @@ fun env -> + T.Strategy_process.with_session ~env + ~command:[ "./fake_strategy.py"; "spawn-grandchild-success"; pid_path ] + ~timeout:1.0 ~transcript_path ~initialization:(initialization ()) + (fun _ -> raise Exit) + in + let message = diagnostic_message result in + Alcotest.(check bool) + "callback exception reported" true + (String.ends_with ~suffix:"Stdlib.Exit" message); + grandchild_pid pid_path |> check_process_gone + +let cancellation_reaps_process_tree () = + with_process_tree_paths @@ fun pid_path transcript_path -> + let timed_out = + Eio_main.run @@ fun env -> + try + ignore + (Eio.Time.with_timeout_exn (Eio.Stdenv.clock env) 0.2 (fun () -> + T.Strategy_process.with_session ~env + ~command: + [ "./fake_strategy.py"; "spawn-grandchild-success"; pid_path ] + ~timeout:1.0 ~transcript_path ~initialization:(initialization ()) + (fun _ -> + Eio.Time.sleep (Eio.Stdenv.clock env) 60.0; + Ok ()))); + false + with Eio.Time.Timeout -> true + in + Alcotest.(check bool) "session cancellation timed out" true timed_out; + grandchild_pid pid_path |> check_process_gone + let tests = [ Alcotest.test_case "initialize message is complete" `Quick initialize_message_is_complete; + Alcotest.test_case "initialize message includes calendars" `Quick + initialize_message_includes_calendars; + Alcotest.test_case "conservative initialization encodes costs" `Quick + conservative_initialize_message_encodes_cost_models; Alcotest.test_case "event context is complete" `Quick event_message_contains_complete_context; Alcotest.test_case "nonpositive equity omits weights" `Quick nonpositive_equity_omits_weights; Alcotest.test_case "responses are strict and typed" `Quick responses_are_strict_and_typed; + Alcotest.test_case "strategy configuration is bounded" `Quick + strategy_configuration_is_validated_before_use; + Alcotest.test_case "configuration precedes transcript and process" `Quick + invalid_configuration_creates_no_transcript_or_process; Alcotest.test_case "transcript records direction" `Quick transcript_records_direction_and_sequence; + Alcotest.test_case "callback exception reaps process tree" `Slow + callback_exception_reaps_process_tree; + Alcotest.test_case "cancellation reaps process tree" `Slow + cancellation_reaps_process_tree; ] diff --git a/test/test_support.ml b/test/test_support.ml index a22e677..21aaaef 100644 --- a/test/test_support.ml +++ b/test/test_support.ml @@ -1,11 +1,14 @@ module T = Trading_engine -let ok = function Ok value -> value | Error message -> Alcotest.fail message +let ok = function + | Ok value -> value + | Error _ -> Alcotest.fail "unexpected error" let error = function | Error message -> message | Ok _ -> Alcotest.fail "expected an error" +let diagnostic_message result = error result |> T.Diagnostic.to_human let price value = T.Scalar.Price.of_decimal_string value |> ok let quantity value = T.Scalar.Quantity.of_decimal_string value |> ok let weight value = T.Scalar.Weight.of_decimal_string value |> ok @@ -51,6 +54,10 @@ let test_account ?(base_currency = "USD") ?initial_cash () = in T.Account.create ~base_currency ~initial_cash |> ok +let initial_portfolio ?(base_currency = "USD") ?cash () = + let cash = Option.value cash ~default:[ (base_currency, money "10000") ] in + T.Initial_portfolio.cash_only ~base_currency ~cash |> ok + let account_cash ?(currency = "USD") account = T.Account.cash account currency |> Option.get @@ -58,8 +65,11 @@ let account_value ?(instruments = [ instrument () ]) ?(fx_rates = [ ("USD", price "1") ]) account ~marks = T.Account.value account ~instruments ~marks ~fx_rates |> ok -let market_slice ?bars ?fx_rates ?(corporate_actions = []) ?start_at ?end_at - ?available_at ?received_at sequence = +let market_slice ?bars ?fx_rates ?(corporate_actions = []) + ?(borrow_observations = []) ?(cash_rate_observations = []) + ?(settlement_failures = []) ?(lifecycle_events = []) ?(market_events = []) + ?(order_book_events = []) ?start_at ?end_at ?available_at ?received_at + sequence = let day = day sequence in let start_at = Option.value start_at @@ -80,14 +90,26 @@ let market_slice ?bars ?fx_rates ?(corporate_actions = []) ?start_at ?end_at let bars = Option.value bars ~default:[ bar sequence ] in let fx_rates = Option.value fx_rates ~default:[ fx_mark () ] in T.Market_slice.create ~slice_sequence:sequence ~start_at ~end_at ~available_at - ~received_at ~bars ~fx_rates ~corporate_actions + ~received_at ~bars ~fx_rates ~corporate_actions ~borrow_observations + ~cash_rate_observations ~settlement_failures ~lifecycle_events + ~market_events ~order_book_events |> ok let request ?(instrument = instrument_id "test-equity") ?(side = T.Order.Buy) ?(quantity_value = "10") ?(kind = T.Order.Market) ?(origin = T.Order.Direct) - () = + ?time_in_force () = + let time_in_force = + Option.value time_in_force ~default:(T.Order.default_time_in_force kind) + in + T.Order.request ~instrument_id:instrument ~side + ~quantity:(quantity quantity_value) ~kind ~time_in_force ~origin + |> ok + +let current_request ?(instrument = instrument_id "test-equity") + ?(side = T.Order.Buy) ?(quantity_value = "10") ?(kind = T.Order.Market) + ?(time_in_force = T.Order.Gtc) ?(origin = T.Order.Direct) () = T.Order.request ~instrument_id:instrument ~side - ~quantity:(quantity quantity_value) ~kind ~origin + ~quantity:(quantity quantity_value) ~kind ~time_in_force ~origin |> ok let accepted_order ?(id = "order-1") ?(accepted_sequence = 1L) @@ -112,36 +134,95 @@ let fill ?(id = "fill-1") ?(price_value = "100") ?(quantity_value = "1") T.Fill.create ~id:(fill_id id) ~order_id:order.T.Order.id ~instrument_id:order.request.instrument_id ~side:order.request.side ~quote_currency:"USD" ~quantity:(quantity quantity_value) - ~price:(price price_value) ~fee:(money fee_value) ~executed_at - ~slice_sequence + ~price:(price price_value) ~fee:(money fee_value) ~fee_components:[] + ~executed_at ~slice_sequence |> ok -let execution ?(participation_bps = 10_000) ?(fixed_fee = "0") ?(fee_bps = 0) () - = - T.Execution.create ~participation_bps ~fixed_fee:(money fixed_fee) ~fee_bps +let zero_fee_schedule instrument = + let component = + T.Fee_schedule.create_component ~name:"zero" + ~currency:instrument.T.Instrument.quote_currency + ~basis:(T.Fee_schedule.Fixed T.Scalar.Money.zero) + ~rounding:T.Fee_schedule.Up ~applicability:T.Fee_schedule.Any + |> ok + in + T.Fee_schedule.create + ~schedule_id:(T.Id.Instrument.to_string instrument.id ^ "-fees-v1") + ~instrument_id:instrument.id ~settlement_currency:instrument.quote_currency + ~minimum:None ~maximum:None ~components:[ component ] |> ok +let execution ?(participation_bps = 10_000) ?fee_schedules + ?(instruments = [ instrument () ]) () = + let fee_schedules = + Option.value fee_schedules ~default:(List.map zero_fee_schedule instruments) + in + T.Execution.create ~participation_bps ~fee_schedules |> ok + let risk ?(base_currency = "USD") ?(instruments = [ instrument () ]) ?(max_order = "1000") ?(max_long = "1000") ?(max_short = "1000") ?(max_gross = "1000000000") ?(max_leverage = "2") - ?(initial_margin_bps = 5000) ?(maintenance_margin_bps = 2500) - ?(short_borrow_bps = 100) () = - T.Risk.create ~base_currency ~instruments - ~max_order_quantity:(quantity max_order) - ~max_long_position:(quantity max_long) - ~max_short_position:(quantity max_short) + ?(initial_margin_bps = 5000) ?(maintenance_margin_bps = 2500) () = + let instrument_policies = + List.map + (fun instrument -> + T.Risk.create_instrument_policy ~instrument + ~max_order_quantity:(quantity max_order) + ~max_long_position:(quantity max_long) + ~max_short_position:(quantity max_short) ~max_notional_exposure:None + ~initial_margin_bps ~maintenance_margin_bps ~shorting_allowed:true + |> ok) + instruments + in + T.Risk.create ~base_currency ~instruments ~instrument_policies ~groups:[] ~max_gross_exposure:(money max_gross) ~max_leverage:(T.Scalar.Ratio.of_decimal_string max_leverage |> ok) - ~initial_margin_bps ~maintenance_margin_bps ~short_borrow_bps |> ok -let engine_config ?(risk = risk ()) ?execution_model ?(execution = execution ()) - ?(max_internal_events = 1000) () = +let financing_policy () = + T.Financing.policy ~day_count:T.Financing.Actual_365 + ~compounding:T.Financing.Simple ~borrow_missing_data:T.Financing.Zero + ~cash_missing_data:T.Financing.Zero ~locate_policy:T.Financing.Clip_fill + ~recall_policy:T.Financing.Reject_new_shorts + +let settlement_policy ?(instruments = [ instrument () ]) () = + let dates month count = + List.init count (fun index -> + Printf.sprintf "2026-%02d-%02d" month (index + 1)) + in + let calendar = + T.Settlement.calendar ~calendar_id:"test-settlement" ~version:"1" + ~business_dates:(dates 1 31 @ dates 2 28 @ dates 3 31) + |> ok + in + let rules = + List.map + (fun instrument -> + T.Settlement.rule ~instrument_id:instrument.T.Instrument.id + ~calendar_id:"test-settlement" ~lag_business_days:1 + |> ok) + instruments + in + T.Settlement.policy ~cash_buying_power:T.Settlement.Total_cash + ~position_availability:T.Settlement.Total_positions ~calendars:[ calendar ] + ~rules + |> ok + +let engine_config ?(contract_version = T.Contract.version) ?(risk = risk ()) + ?(venue_calendars = []) ?execution_model ?(execution = execution ()) + ?(financing = financing_policy ()) ?settlement ?(max_internal_events = 1000) + () = let execution_model = Option.value execution_model ~default:(T.Execution_model.find "completed_bar_v1" |> ok) in - T.Engine.config ~risk ~execution_model ~execution ~max_internal_events |> ok + let settlement = + Option.value settlement + ~default:(settlement_policy ~instruments:(T.Risk.instruments risk) ()) + in + T.Engine.config ~contract_version ~risk ~venue_calendars ~execution_model + ~execution ~financing ~settlement ~max_internal_events + |> ok let risk_check risk ~account ~oms request = T.Risk.check risk ~account ~oms diff --git a/test/test_venue_calendar.ml b/test/test_venue_calendar.ml new file mode 100644 index 0000000..8d028a6 --- /dev/null +++ b/test/test_venue_calendar.ml @@ -0,0 +1,155 @@ +open Test_support +module T = Trading_engine + +let phase kind opens_at closes_at = + T.Venue_calendar.create_phase ~kind ~opens_at:(timestamp opens_at) + ~closes_at:(timestamp closes_at) + |> ok + +let explicit_session_policies () = + let regular = + T.Venue_calendar.create_session ~session_date:"2026-01-02" + ~kind:T.Venue_calendar.Regular_session + ~phases: + [ + phase T.Venue_calendar.Premarket "2026-01-02T09:00:00Z" + "2026-01-02T14:25:00Z"; + phase T.Venue_calendar.Opening_auction "2026-01-02T14:25:00Z" + "2026-01-02T14:30:00Z"; + phase T.Venue_calendar.Regular "2026-01-02T14:30:00Z" + "2026-01-02T20:55:00Z"; + phase T.Venue_calendar.Closing_auction "2026-01-02T20:55:00Z" + "2026-01-02T21:00:00Z"; + phase T.Venue_calendar.Postmarket "2026-01-02T21:00:00Z" + "2026-01-03T01:00:00Z"; + ] + |> ok + in + let holiday = + T.Venue_calendar.create_session ~session_date:"2026-01-03" + ~kind:T.Venue_calendar.Holiday ~phases:[] + |> ok + in + let early_close = + T.Venue_calendar.create_session ~session_date:"2026-01-05" + ~kind:T.Venue_calendar.Early_close + ~phases: + [ + phase T.Venue_calendar.Regular "2026-01-05T14:30:00Z" + "2026-01-05T18:00:00Z"; + ] + |> ok + in + let calendar = + T.Venue_calendar.create + ~id:(T.Id.Venue_calendar.of_string_exn "xnas-2026") + ~version:"1" + ~venue_id:(T.Id.Venue.of_string_exn "XNAS") + ~instrument_ids:[ instrument_id "demo-equity-acme" ] + ~sessions:[ regular; holiday; early_close ] + |> ok + in + let selected = + T.Venue_calendar.session_on calendar ~session_date:"2026-01-05" |> ok + in + Alcotest.(check string) + "early-close policy" "early_close" + (T.Venue_calendar.session_kind_to_string selected.kind); + let missing = + T.Venue_calendar.session_on calendar ~session_date:"2026-01-04" |> error + in + Alcotest.(check string) + "missing policy is not inferred" + "venue calendar xnas-2026 version 1 has no explicit policy for 2026-01-04" + missing + +let ambiguous_phase_policies_are_rejected () = + let regular = + phase T.Venue_calendar.Regular "2026-01-02T14:30:00Z" "2026-01-02T21:00:00Z" + in + let overlapping = + phase T.Venue_calendar.Postmarket "2026-01-02T20:00:00Z" + "2026-01-03T01:00:00Z" + in + Alcotest.(check bool) + "overlap rejected" true + (Result.is_error + (T.Venue_calendar.create_session ~session_date:"2026-01-02" + ~kind:T.Venue_calendar.Regular_session + ~phases:[ regular; overlapping ])); + Alcotest.(check bool) + "missing regular phase rejected" true + (Result.is_error + (T.Venue_calendar.create_session ~session_date:"2026-01-02" + ~kind:T.Venue_calendar.Regular_session + ~phases: + [ + phase T.Venue_calendar.Premarket "2026-01-02T09:00:00Z" + "2026-01-02T14:00:00Z"; + ])); + Alcotest.(check bool) + "holiday phases rejected" true + (Result.is_error + (T.Venue_calendar.create_session ~session_date:"2026-01-02" + ~kind:T.Venue_calendar.Holiday ~phases:[ regular ])) + +let scenario_contract_requires_calendar_coverage () = + let document = + Yojson.Safe.from_file "../contracts/v1/fixtures/demo.scenario.json" + in + let scenario = T.Scenario.of_yojson document |> ok in + Alcotest.(check int) + "calendar retained" 1 + (List.length scenario.venue_calendars); + let missing = + match document with + | `Assoc fields -> + `Assoc + (List.filter + (fun (name, _) -> not (String.equal name "venue_calendars")) + fields) + | json -> json + in + let missing = T.Scenario.of_yojson missing |> error in + Alcotest.(check (option string)) + "missing calendar path" (Some "$") missing.context.json_path; + let wrong_member = + match document with + | `Assoc fields -> + `Assoc + (List.map + (fun (name, value) -> + if not (String.equal name "venue_calendars") then (name, value) + else + let calendars = + match value with + | `List [ `Assoc calendar ] -> + `List + [ + `Assoc + (List.map + (fun (field, value) -> + if String.equal field "instrument_ids" then + (field, `List [ `String "unknown" ]) + else (field, value)) + calendar); + ] + | _ -> Alcotest.fail "fixture calendar shape changed" + in + (name, calendars)) + fields) + | _ -> Alcotest.fail "fixture scenario must be an object" + in + let uncovered = T.Scenario.of_yojson wrong_member |> error in + Alcotest.(check (option string)) + "coverage path" (Some "$.venue_calendars") uncovered.context.json_path + +let tests = + [ + Alcotest.test_case "explicit policies and missing dates" `Quick + explicit_session_policies; + Alcotest.test_case "ambiguous phases rejected" `Quick + ambiguous_phase_policies_are_rejected; + Alcotest.test_case "scenario calendar coverage" `Quick + scenario_contract_requires_calendar_coverage; + ] diff --git a/test/validate_cli_result.py b/test/validate_cli_result.py new file mode 100644 index 0000000..62664b6 --- /dev/null +++ b/test/validate_cli_result.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Validate live CLI success results against their versioned JSON Schema.""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +from jsonschema import Draft202012Validator +from referencing import Registry, Resource + + +def load(path: Path) -> object: + return json.loads(path.read_text(encoding="utf-8")) + + +def run(binary: Path, *arguments: str) -> tuple[object, str]: + completed = subprocess.run( + [str(binary), "--output-format", "json", *arguments], + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout), completed.stderr + + +def main() -> None: + if len(sys.argv) != 5: + raise SystemExit( + "usage: validate_cli_result.py RESULT_SCHEMA JOURNAL_SCHEMA BINARY SCENARIO" + ) + result_path, journal_path, binary, scenario = map(Path, sys.argv[1:]) + result_schema = load(result_path) + journal_schema = load(journal_path) + Draft202012Validator.check_schema(result_schema) + registry = Registry().with_resource( + journal_schema["$id"], Resource.from_contents(journal_schema) + ) + validator = Draft202012Validator(result_schema, registry=registry) + + validation, validation_stderr = run( + binary, "--validate-only", "--input", str(scenario) + ) + validator.validate(validation) + assert validation_stderr == "" + assert validation["operation"] == "validate" + + with tempfile.TemporaryDirectory() as directory: + journal = Path(directory) / "run.journal.jsonl" + replay, replay_stderr = run( + binary, "--input", str(scenario), "--journal", str(journal) + ) + validator.validate(replay) + assert replay_stderr == "" + assert replay["operation"] == "replay" + assert replay["hashes"]["journal_sha256"] == hashlib.sha256( + journal.read_bytes() + ).hexdigest() + + +if __name__ == "__main__": + main() diff --git a/test/validate_contract_conformance.py b/test/validate_contract_conformance.py new file mode 100644 index 0000000..f2d1bf7 --- /dev/null +++ b/test/validate_contract_conformance.py @@ -0,0 +1,404 @@ +#!/usr/bin/env python3 +"""Validate every committed contract branch and the differential corpus.""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path +from typing import Any + +from jsonschema.exceptions import ValidationError +from jsonschema.validators import validator_for +from referencing import Registry, Resource + + +ROOT = Path(__file__).resolve().parents[1] +CONTRACTS = ROOT / "contracts" +CONFORMANCE = CONTRACTS / "conformance" + + +def loads(document: str, label: str) -> Any: + def object_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate key {key!r} in {label}") + result[key] = value + return result + + return json.loads( + document, + object_pairs_hook=object_pairs, + parse_constant=lambda value: (_ for _ in ()).throw( + ValueError(f"non-finite number {value!r} in {label}") + ), + ) + + +def load(path: Path) -> Any: + return loads(path.read_text(encoding="utf-8"), str(path.relative_to(ROOT))) + + +def extract(instance: Any, path: list[str | int]) -> Any | None: + current = instance + for component in path: + if isinstance(component, str) and isinstance(current, dict): + if component not in current: + return None + current = current[component] + elif isinstance(component, int) and isinstance(current, list): + current = current[component] + else: + raise AssertionError(f"cannot extract {path!r} from {instance!r}") + return current + + +def source_instances(source: dict[str, Any]) -> list[tuple[int, Any]]: + path = CONTRACTS / source["path"] + if source["format"] == "json": + records = [(1, load(path))] + elif source["format"] == "jsonl": + records = [ + (line_number, loads(line, f"{path.relative_to(ROOT)}:{line_number}")) + for line_number, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), start=1 + ) + if line + ] + else: + raise AssertionError(f"unsupported source format {source['format']!r}") + extraction = source.get("extract", []) + selected = [ + (line_number, extracted) + for line_number, record in records + if (extracted := extract(record, extraction)) is not None + ] + if not selected: + raise AssertionError(f"{source['path']} selected no conformance inputs") + return selected + + +def schema_paths() -> set[Path]: + return { + path + for path in CONTRACTS.rglob("*.schema.json") + if "conformance" not in path.parts + } + + +def fixture_paths() -> set[Path]: + return { + path + for path in CONTRACTS.rglob("fixtures/*") + if path.is_file() and "conformance" not in path.parts + } + + +def schema_registry() -> tuple[dict[str, Any], Registry[Any]]: + schemas = { + str(path.relative_to(CONTRACTS)): load(path) for path in schema_paths() + } + resources = [] + for relative, schema in schemas.items(): + if not isinstance(schema, dict): + raise AssertionError(f"{relative} must contain a JSON object") + if "$schema" not in schema or "$id" not in schema: + raise AssertionError(f"{relative} must declare $schema and $id") + validator_class = validator_for(schema) + if schema["$schema"] != validator_class.META_SCHEMA.get("$id"): + raise AssertionError( + f"{relative} declares unsupported draft {schema['$schema']!r}" + ) + validator_class.check_schema(schema) + resources.append((schema["$id"], Resource.from_contents(schema))) + registry: Registry[Any] = Registry().with_resources(resources) + for relative, schema in schemas.items(): + resolver = registry.resolver(schema["$id"]) + for reference in references(schema): + try: + resolver.lookup(reference) + except Exception as error: + raise AssertionError( + f"broken reference {reference!r} in {relative}" + ) from error + return schemas, registry + + +def references(value: Any) -> list[str]: + if isinstance(value, dict): + found = [value["$ref"]] if isinstance(value.get("$ref"), str) else [] + return found + [ + reference + for child in value.values() + for reference in references(child) + ] + if isinstance(value, list): + return [reference for child in value for reference in references(child)] + return [] + + +def make_validator( + schema: dict[str, Any], registry: Registry[Any] +) -> Any: + validator_class = validator_for(schema) + return validator_class( + schema, + format_checker=validator_class.FORMAT_CHECKER, + registry=registry, + ) + + +def expect_invalid(validator: Any, instance: Any, label: str) -> None: + try: + validator.validate(instance) + except ValidationError: + return + raise AssertionError(f"{label} unexpectedly satisfied its schema") + + +def verify_manifest( + schemas: dict[str, Any], registry: Registry[Any] +) -> tuple[dict[str, dict[str, Any]], dict[str, Any]]: + manifest = load(CONFORMANCE / "manifest.json") + if manifest.get("format_version") != "1": + raise AssertionError("unsupported contract manifest version") + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, list) or not artifacts: + raise AssertionError("contract manifest must list artifacts") + by_name = {artifact["name"]: artifact for artifact in artifacts} + if len(by_name) != len(artifacts): + raise AssertionError("contract manifest artifact names must be unique") + + declared_schemas = {artifact["schema"] for artifact in artifacts} + discovered_schemas = { + str(path.relative_to(CONTRACTS)) for path in schema_paths() + } + if declared_schemas != discovered_schemas: + raise AssertionError( + "contract manifest schema set differs from committed schemas: " + f"declared={sorted(declared_schemas)} " + f"committed={sorted(discovered_schemas)}" + ) + declared_sources = { + source["path"] for artifact in artifacts for source in artifact["sources"] + } + discovered_sources = { + str(path.relative_to(CONTRACTS)) for path in fixture_paths() + } + if declared_sources != discovered_sources: + raise AssertionError( + "contract manifest fixture set differs from committed fixtures: " + f"declared={sorted(declared_sources)} " + f"committed={sorted(discovered_sources)}" + ) + + for artifact in artifacts: + schema = schemas[artifact["schema"]] + validator = make_validator(schema, registry) + inputs = [ + instance + for source in artifact["sources"] + for _, instance in source_instances(source) + ] + for index, instance in enumerate(inputs, start=1): + validator.validate(instance) + if not isinstance(instance, dict): + raise AssertionError(f"{artifact['name']} input must be an object") + version = instance.get(artifact["version_field"]) + if version != artifact["version"]: + raise AssertionError( + f"{artifact['name']} input {index} has version {version!r}" + ) + + representative = inputs[0] + missing_version = copy.deepcopy(representative) + del missing_version[artifact["version_field"]] + expect_invalid(validator, missing_version, f"{artifact['name']} missing version") + unsupported_version = copy.deepcopy(representative) + unsupported_version[artifact["version_field"]] = "__unsupported__" + expect_invalid( + validator, unsupported_version, f"{artifact['name']} unsupported version" + ) + unknown_field = copy.deepcopy(representative) + unknown_field["unexpected_contract_field"] = True + expect_invalid(validator, unknown_field, f"{artifact['name']} unknown field") + return by_name, manifest + + +def resolve_parent(instance: Any, path: list[str | int]) -> tuple[Any, str | int]: + if not path: + raise AssertionError("mutation path must not be empty") + current = instance + for component in path[:-1]: + if isinstance(component, str) and isinstance(current, dict): + current = current[component] + elif isinstance(component, int) and isinstance(current, list): + current = current[component] + else: + raise AssertionError(f"invalid mutation path {path!r}") + return current, path[-1] + + +def apply_mutations(instance: Any, mutations: list[dict[str, Any]]) -> Any: + result = copy.deepcopy(instance) + for mutation in mutations: + operation = mutation["op"] + path = mutation["path"] + if operation == "append_copy": + target = extract(result, path) + if not isinstance(target, list): + raise AssertionError(f"append_copy target {path!r} is not an array") + target.append(copy.deepcopy(target[mutation["index"]])) + continue + parent, component = resolve_parent(result, path) + if operation == "remove": + if not isinstance(parent, dict) or not isinstance(component, str): + raise AssertionError("remove currently requires an object field") + del parent[component] + elif operation in {"add", "replace"}: + value = copy.deepcopy(mutation["value"]) + if isinstance(parent, dict) and isinstance(component, str): + parent[component] = value + elif isinstance(parent, list) and isinstance(component, int): + parent[component] = value + else: + raise AssertionError(f"invalid mutation target {path!r}") + else: + raise AssertionError(f"unsupported mutation operation {operation!r}") + return result + + +def case_instances(case: dict[str, Any]) -> list[Any]: + if "instance" in case: + return [apply_mutations(case["instance"], case["mutations"])] + source = { + "path": case["source"], + "format": "jsonl" if case["source"].endswith(".jsonl") else "json", + "extract": case.get("extract", []), + } + inputs = source_instances(source) + record = case.get("record") + if record is not None: + inputs = [instance for line, instance in inputs if line == record] + if len(inputs) != 1: + raise AssertionError( + f"{case['name']} did not select exactly one source record" + ) + else: + inputs = [instance for _, instance in inputs] + mutations = case["mutations"] + if mutations and len(inputs) != 1: + raise AssertionError(f"{case['name']} mutates more than one source record") + return [apply_mutations(instance, mutations) for instance in inputs] + + +def verify_cases( + artifacts: dict[str, dict[str, Any]], + schemas: dict[str, Any], + registry: Registry[Any], +) -> dict[str, list[Any]]: + corpus = load(CONFORMANCE / "cases.json") + if corpus.get("format_version") != "1": + raise AssertionError("unsupported differential corpus version") + cases = corpus.get("cases") + if not isinstance(cases, list) or not cases: + raise AssertionError("differential corpus must list cases") + names = {case["name"] for case in cases} + if len(names) != len(cases): + raise AssertionError("differential case names must be unique") + schema_only_cases = corpus.get("schema_only_cases") + if not isinstance(schema_only_cases, list): + raise AssertionError("differential corpus must list schema-only cases") + all_cases = [*cases, *schema_only_cases] + all_names = {case["name"] for case in all_cases} + if len(all_names) != len(all_cases): + raise AssertionError("all conformance case names must be unique") + accepted_by_artifact: dict[str, list[Any]] = {} + for case in all_cases: + artifact = artifacts[case["artifact"]] + validator = make_validator(schemas[artifact["schema"]], registry) + inputs = case_instances(case) + schema_accepts = True + try: + for instance in inputs: + validator.validate(instance) + except ValidationError: + schema_accepts = False + expected_schema = case["schema_expectation"] == "accept" + if schema_accepts != expected_schema: + raise AssertionError( + f"{case['name']} schema expectation was " + f"{case['schema_expectation']}" + ) + if schema_accepts: + accepted_by_artifact.setdefault(case["artifact"], []).extend(inputs) + if case in schema_only_cases: + continue + schema_expectation = case["schema_expectation"] + runtime_expectation = case["runtime_expectation"] + if case["rule"] == "structural" and schema_expectation != runtime_expectation: + raise AssertionError( + f"{case['name']} structural expectations must agree" + ) + if case["rule"] == "semantic" and ( + schema_expectation != "accept" or runtime_expectation != "reject" + ): + raise AssertionError( + f"{case['name']} semantic case must document schema accept/runtime reject" + ) + return accepted_by_artifact + + +def verify_top_level_branches( + artifacts: dict[str, dict[str, Any]], + schemas: dict[str, Any], + registry: Registry[Any], + accepted_cases: dict[str, list[Any]], +) -> None: + for artifact_name, artifact in artifacts.items(): + schema = schemas[artifact["schema"]] + branches = schema.get("oneOf", []) + if not branches: + continue + candidates = [ + instance + for source in artifact["sources"] + for _, instance in source_instances(source) + ] + candidates.extend(accepted_cases.get(artifact_name, [])) + full_validator = make_validator(schema, registry) + for branch_index, branch in enumerate(branches, start=1): + branch_schema = copy.deepcopy(schema) + branch_schema["oneOf"] = [branch] + branch_validator = make_validator(branch_schema, registry) + witness = next( + (candidate for candidate in candidates if branch_validator.is_valid(candidate)), + None, + ) + if witness is None: + raise AssertionError( + f"{artifact_name} oneOf branch {branch_index} has no positive case" + ) + if not isinstance(witness, dict): + raise AssertionError( + f"{artifact_name} oneOf branch {branch_index} is not an object" + ) + negative = copy.deepcopy(witness) + negative["unexpected_contract_field"] = True + expect_invalid( + full_validator, + negative, + f"{artifact_name} oneOf branch {branch_index} negative case", + ) + + +def main() -> None: + schemas, registry = schema_registry() + artifacts, _ = verify_manifest(schemas, registry) + accepted_cases = verify_cases(artifacts, schemas, registry) + verify_top_level_branches(artifacts, schemas, registry, accepted_cases) + + +if __name__ == "__main__": + main() diff --git a/test/validate_diagnostic_schema.py b/test/validate_diagnostic_schema.py new file mode 100644 index 0000000..d3d6dab --- /dev/null +++ b/test/validate_diagnostic_schema.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Validate the stable diagnostic schema and canonical fixture.""" + +from __future__ import annotations + +import copy +import json +import sys +from pathlib import Path + +from jsonschema import Draft202012Validator +from jsonschema.exceptions import ValidationError + + +def expect_invalid(validator: Draft202012Validator, instance: object) -> None: + try: + validator.validate(instance) + except ValidationError: + return + raise AssertionError("invalid diagnostic unexpectedly passed") + + +def main() -> None: + if len(sys.argv) != 3: + raise SystemExit( + "usage: validate_diagnostic_schema.py DIAGNOSTIC_SCHEMA FIXTURE" + ) + schema_path, fixture_path = map(Path, sys.argv[1:]) + schema = json.loads(schema_path.read_text(encoding="utf-8")) + fixture = json.loads(fixture_path.read_text(encoding="utf-8")) + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema) + validator.validate(fixture) + + unknown_code = copy.deepcopy(fixture) + unknown_code["code"] = "strategy.other" + expect_invalid(validator, unknown_code) + prose_sequence = copy.deepcopy(fixture) + prose_sequence["context"]["sequence"] = "01" + expect_invalid(validator, prose_sequence) + duplicate_cause = copy.deepcopy(fixture) + duplicate_cause["context"]["causation_ids"] *= 2 + expect_invalid(validator, duplicate_cause) + exposed_payload = copy.deepcopy(fixture) + exposed_payload["payload"] = {"secret": True} + expect_invalid(validator, exposed_payload) + incomplete_cause = copy.deepcopy(fixture) + incomplete_cause["cause"] = {"kind": "system_error"} + expect_invalid(validator, incomplete_cause) + + +if __name__ == "__main__": + main() diff --git a/test/validate_schemas.py b/test/validate_schemas.py index f62ca3e..34e115a 100644 --- a/test/validate_schemas.py +++ b/test/validate_schemas.py @@ -69,6 +69,7 @@ def main() -> None: journal_validator = Draft202012Validator( journal_schema, format_checker=Draft202012Validator.FORMAT_CHECKER, + registry=registry, ) stream_validator = Draft202012Validator( stream_schema, @@ -77,7 +78,6 @@ def main() -> None: ) scenario = load(scenario_path) - contract_version = scenario["contract_version"] unsupported_version = "unsupported" scenario_validator.validate(scenario) stream_records = [ @@ -115,10 +115,25 @@ def main() -> None: unsupported_execution_model = copy.deepcopy(scenario) unsupported_execution_model["execution"]["model"] = "future_model" expect_invalid(scenario_validator, unsupported_execution_model) - if contract_version == "3": - excessive_feedback_cap = copy.deepcopy(scenario) - excessive_feedback_cap["max_internal_events"] = 4611686018427387904 - expect_invalid(scenario_validator, excessive_feedback_cap) + missing_configuration_version = copy.deepcopy(scenario) + del missing_configuration_version["execution"]["configuration"]["version"] + expect_invalid(scenario_validator, missing_configuration_version) + unsupported_configuration_version = copy.deepcopy(scenario) + unsupported_configuration_version["execution"]["configuration"]["version"] = "2" + expect_invalid(scenario_validator, unsupported_configuration_version) + unknown_configuration_field = copy.deepcopy(scenario) + unknown_configuration_field["execution"]["configuration"]["future"] = True + expect_invalid(scenario_validator, unknown_configuration_field) + excessive_feedback_cap = copy.deepcopy(scenario) + excessive_feedback_cap["max_internal_events"] = 100001 + expect_invalid(scenario_validator, excessive_feedback_cap) + excessive_catalog = copy.deepcopy(scenario) + excessive_catalog["instruments"] = [scenario["instruments"][0]] * 4097 + expect_invalid(scenario_validator, excessive_catalog) + excessive_intents = copy.deepcopy(scenario) + intent = scenario["schedule"][0]["intents"][0] + excessive_intents["schedule"][0]["intents"] = [intent] * 4097 + expect_invalid(scenario_validator, excessive_intents) unversioned_stream_record = copy.deepcopy(stream_records[0]) del unversioned_stream_record["contract_version"] expect_invalid(stream_validator, unversioned_stream_record) @@ -128,11 +143,17 @@ def main() -> None: malformed_stream_slice = copy.deepcopy(stream_records[1]) malformed_stream_slice["payload"]["market_slice"]["unexpected"] = True expect_invalid(stream_validator, malformed_stream_slice) + excessive_stream_catalog = copy.deepcopy(stream_records[0]) + excessive_stream_catalog["payload"]["instruments"] = ( + [stream_records[0]["payload"]["instruments"][0]] * 4097 + ) + expect_invalid(stream_validator, excessive_stream_catalog) + excessive_stream_intents = copy.deepcopy(stream_records[1]) + intent = scenario["schedule"][0]["intents"][0] + excessive_stream_intents["payload"]["intents"] = [intent] * 4097 + expect_invalid(stream_validator, excessive_stream_intents) noncanonical = copy.deepcopy(scenario) - if contract_version == "3": - noncanonical["initial_cash"][0]["amount"] = "10000.0" - else: - noncanonical["initial_cash"] = "10000.0" + noncanonical["initial_portfolio"]["cash"][0]["amount"] = "10000.0" expect_invalid(scenario_validator, noncanonical) first_journal_record = json.loads( journal_path.read_text(encoding="utf-8").splitlines()[0] @@ -153,6 +174,30 @@ def main() -> None: json.loads(line) for line in journal_path.read_text(encoding="utf-8").splitlines() ] + fill_clipped = copy.deepcopy(first_journal_record) + fill_clipped["event_type"] = "fill_clipped" + fill_clipped["payload"] = { + "reason": { + "version": "1", + "policy": "max_leverage", + "threshold": {"unit": "ratio", "value": "2"}, + }, + "order_id": "fixture-order", + "instrument_id": "fixture-instrument", + "proposed_quantity": "10", + "permitted_quantity": "5", + "price": "100", + } + journal_validator.validate(fill_clipped) + mismatched_threshold = copy.deepcopy(fill_clipped) + mismatched_threshold["payload"]["reason"]["threshold"] = { + "unit": "money", + "value": "2", + } + expect_invalid(journal_validator, mismatched_threshold) + unknown_policy = copy.deepcopy(fill_clipped) + unknown_policy["payload"]["reason"]["policy"] = "future_policy" + expect_invalid(journal_validator, unknown_policy) order_record = next( record for record in journal_records if record["event_type"] == "order_accepted" ) diff --git a/test/validate_strategy_schema.py b/test/validate_strategy_schema.py index 5903c41..2df99de 100644 --- a/test/validate_strategy_schema.py +++ b/test/validate_strategy_schema.py @@ -34,15 +34,23 @@ def expect_invalid(validator: Draft202012Validator, instance: object) -> None: def main() -> None: - if len(sys.argv) != 6: + if len(sys.argv) != 7: raise SystemExit( "usage: validate_strategy_schema.py SCENARIO_SCHEMA JOURNAL_SCHEMA " - "MESSAGE_SCHEMA TRANSCRIPT_SCHEMA TRANSCRIPT" + "DIAGNOSTIC_SCHEMA MESSAGE_SCHEMA TRANSCRIPT_SCHEMA TRANSCRIPT" ) - scenario_path, journal_path, message_path, transcript_path, fixture_path = map( - Path, sys.argv[1:] - ) - schemas = [load(path) for path in (scenario_path, journal_path, message_path)] + ( + scenario_path, + journal_path, + diagnostic_path, + message_path, + transcript_path, + fixture_path, + ) = map(Path, sys.argv[1:]) + schemas = [ + load(path) + for path in (scenario_path, journal_path, diagnostic_path, message_path) + ] transcript_schema = load(transcript_path) for schema in [*schemas, transcript_schema]: Draft202012Validator.check_schema(schema) @@ -85,15 +93,59 @@ def main() -> None: extra_field["unexpected"] = True expect_invalid(message_validator, extra_field) unsupported_version = copy.deepcopy(records[1]["message"]) - unsupported_version["strategy_protocol_version"] = "1" + unsupported_version["strategy_protocol_version"] = "2" expect_invalid(message_validator, unsupported_version) malformed_sequence = copy.deepcopy(records[1]["message"]) malformed_sequence["strategy_sequence"] = "01" expect_invalid(message_validator, malformed_sequence) + intents = next( + copy.deepcopy(record["message"]) + for record in records + if record["message"]["message_type"] == "intents" + and record["message"]["payload"]["intents"] + ) + intent = intents["payload"]["intents"][0] + intents["payload"]["intents"] = [intent] * 4097 + expect_invalid(message_validator, intents) malformed_transcript = copy.deepcopy(records[0]) malformed_transcript["direction"] = "network" expect_invalid(transcript_validator, malformed_transcript) + rejected_response = { + "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": None, + }, + "evidence": { + "encoding": "hex", + "prefix": "7b", + "observed_bytes": 1, + "truncated": False, + }, + } + transcript_validator.validate(rejected_response) + assert "direction" not in rejected_response + assert "message" not in rejected_response + + rejection_as_exchange = copy.deepcopy(rejected_response) + rejection_as_exchange["direction"] = "strategy_to_engine" + rejection_as_exchange["message"] = records[1]["message"] + expect_invalid(transcript_validator, rejection_as_exchange) + oversized_prefix = copy.deepcopy(rejected_response) + oversized_prefix["evidence"]["prefix"] = "00" * 257 + expect_invalid(transcript_validator, oversized_prefix) + unversioned_rejection = copy.deepcopy(rejected_response) + del unversioned_rejection["strategy_diagnostic_version"] + expect_invalid(transcript_validator, unversioned_rejection) + if __name__ == "__main__": main() diff --git a/trading_engine.opam b/trading_engine.opam index 6d647a8..218ff84 100644 --- a/trading_engine.opam +++ b/trading_engine.opam @@ -9,7 +9,7 @@ an append-only audit journal. maintainer: "James Mallette " authors: ["James Mallette "] license: "MIT" -homepage: "https://github.com/fallblu/trading-engine" +homepage: "https://fallblu.github.io/trading-engine/" bug-reports: "https://github.com/fallblu/trading-engine/issues" build: [ ["dune" "subst"] {dev} @@ -30,5 +30,6 @@ depends: [ "alcotest" {with-test & >= "1.9.1" & < "2.0"} "qcheck-core" {with-test & >= "0.91" & < "1.0"} "qcheck-alcotest" {with-test & >= "0.91" & < "1.0"} + "bisect_ppx" {with-test} "odoc" {with-doc} ] diff --git a/trading_engine.opam.locked b/trading_engine.opam.locked index 1e3eb40..54dbfb9 100644 --- a/trading_engine.opam.locked +++ b/trading_engine.opam.locked @@ -20,6 +20,7 @@ depends: [ "base-threads" {= "base"} "base-unix" {= "base"} "bigstringaf" {= "0.10.0"} + "bisect_ppx" {= "dev" & with-test} "camlp-streams" {= "5.0.1" & with-test} "cmdliner" {= "2.1.1"} "compiler-cloning" {= "enabled"} @@ -54,6 +55,7 @@ depends: [ "ocaml" {= "5.5.0"} "ocaml-base-compiler" {= "5.5.0"} "ocaml-compiler" {= "5.5.0"} + "ocaml-compiler-libs" {= "v0.17.0" & with-test} "ocaml-options-vanilla" {= "1"} "ocaml-syntax-shims" {= "1.0.0" & with-test} "ocaml-version" {= "4.1.3" & with-test} @@ -66,6 +68,8 @@ depends: [ "odoc" {= "3.2.1" & with-doc} "odoc-parser" {= "3.2.1" & with-doc} "optint" {= "0.3.0"} + "ppx_derivers" {= "1.2.1" & with-test} + "ppxlib" {= "0.38.0" & with-test} "psq" {= "0.2.1"} "ptime" {= "1.2.0"} "qcheck-alcotest" {= "0.91" & with-test} @@ -90,5 +94,5 @@ build: [ ["dune" "build" "-p" name "-j" jobs] ] license: "MIT" -homepage: "https://github.com/fallblu/trading-engine" -bug-reports: "https://github.com/fallblu/trading-engine/issues" \ No newline at end of file +homepage: "https://fallblu.github.io/trading-engine/" +bug-reports: "https://github.com/fallblu/trading-engine/issues"