diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug-report.md similarity index 86% rename from .github/ISSUE_TEMPLATE/bug_report.md rename to .github/ISSUE_TEMPLATE/bug-report.md index 4b5f5ed..f9bf041 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -27,4 +27,4 @@ What should happen? ## Logs / Output -Paste relevant logs, stack traces, or event output here. +Paste relevant logs, traces, or outputs here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature-request.md similarity index 100% rename from .github/ISSUE_TEMPLATE/feature_request.md rename to .github/ISSUE_TEMPLATE/feature-request.md diff --git a/.gitignore b/.gitignore index df97312..ec65e06 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,6 @@ venv.bak/ *.key *.crt *.pub -.oci/ # ============================== # Python packaging / build artifacts diff --git a/CHANGELOG.md b/CHANGELOG.md index 39c7683..97050db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,60 +1,24 @@ # Changelog -All notable changes to this project will be documented in this file. - -The format is based on Keep a Changelog -and this project adheres to Semantic Versioning. +This changelog starts from the clean Core package baseline. ## [Unreleased] -## [0.1.0] - 2026-02-17 +### Added -Initial public release of the core. +- Deterministic `run_core_step` and `run_core_wakeup_step` architecture. +- CoreWakeupStep final-state Strategy evaluation: reduce all entries, then `CoreWakeupStrategyEvaluator` once. +- Canonical Event input models and `EventStreamEntry`/`ProcessingPosition`. +- Intent candidate record pipeline with dominance/reconciliation. +- Risk Engine (policy-only) admission and Execution Control plan/apply integration. +- `CoreStepResult.dispatchable_intents` and `ControlSchedulingObligation` outputs. +- Core-only quickstart example and focused semantics test coverage. -### Added +### Changed + +- Package metadata, exports, and docs reset for standalone Core library identity. +- Pydantic models established as contract source of truth across public API docs. + +### Removed -#### Core Domain -- Explicit order state machine -- Structured domain types and reject reasons -- Slot-based order tracking -- Event bus and event sink abstractions -- JSON schema validation for domain events - -#### Risk Layer -- Configurable risk engine -- Risk constraint enforcement -- Deterministic risk gating before execution - -#### Backtest Layer -- Integration with [hftbacktest](https://github.com/nkaz001/hftbacktest) -- Strategy runner abstraction -- Venue adapter interface -- Deterministic event processing pipeline - -#### Orchestration -- Segment-based execution model -- Parameter sweep runtime -- Experiment and segment entrypoints -- Prometheus metrics integration -- MLflow-compatible logging hooks - -#### Execution Modes -- Fully local execution example -- Cloud-native runtime entrypoints -- S3-compatible storage adapter - -#### Strategy -- Base strategy interface -- Structured strategy configuration - -#### Testing -- Semantic invariant test suite -- Order state transition validation -- Queue dominance rules -- Risk constraint validation -- Schema conformance tests - -#### Tooling -- Dev container configuration -- Development validation scripts -- Dependency compilation helper +- Legacy compatibility-first contracts and references not part of the clean baseline. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0694e11..1f984aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,63 +1,80 @@ -# Contributing +# Contributing to TradingChassis Core -Thank you for your interest in contributing! +Contributions should preserve TradingChassis Core as a deterministic, +Runtime-agnostic library. -This repository focuses on deterministic, event-driven trading architecture. -Contributions should preserve clarity, explicitness and reproducibility. +> Terminology: Definitions and related terms match the [canonical +> terminology](https://tradingchassis.github.io/docs/latest/00-guides/terminology/). ---- +## Package Scope -## Design Principles +- Core owns canonical Events, State reduction, Strategy evaluation boundary, + candidate reconciliation, Risk Engine (policy), Execution Control plan/apply, + and `CoreStepResult`. +- Core does not own Runtime orchestration, Venue Adapters, dispatch lifecycle, + or deployment/config wiring. -All contributions must respect the core design philosophy: +## Development Setup -- Determinism over convenience -- Explicit state modeling -- No hidden side effects -- Risk-first architecture -- Clear domain boundaries +From `core`: -Avoid introducing implicit behavior or non-deterministic execution paths. +```bash +python -m pip install -e ".[dev]" +``` ---- +## Validation Commands -## Workflow +Run before opening a PR: -1. Fork the repository -2. Create a feature branch -3. Commit small, logical changes -4. Open a Pull Request with clear description +```bash +python examples/core_step_quickstart.py +python -m pytest -q +python -m build +``` ---- +## Architecture Rules -## Commit Style +- Core accepts canonical Events through `EventStreamEntry` and + `process_event_entry` / `process_canonical_event`. +- Core returns deterministic `CoreStepResult`; Runtime dispatches. +- Do not introduce Runtime imports. +- Pydantic models are the source of truth for contract structure. -Use clear messages: +## Changing Core Behavior -feat: add monitoring overlay -fix: correct SecretProviderClass parameters -docs: update bootstrap instructions +### Canonical Events ---- +- Add Event models in `tradingchassis_core/core/domain/types.py`. +- Register canonical category handling in `core/domain/event_model.py`. +- Update canonical reduction behavior in `core/domain/processing.py`. -## Development Environment +### CoreStep/CoreWakeupStep pipeline -Recommended: +- Update `core/domain/processing_step.py` for deterministic flow changes. +- Keep reconciliation/policy/apply transitions explicit and side-effect-safe. -- Python 3.11.x -- Dev Container (provided in this repository) +### Risk Engine (policy) behavior -Alternatively: +- Implement policy checks in `core/risk/` and wire through + `evaluate_policy_intent`. +- Keep Risk Engine admission as policy-only; no dispatch/Runtime side effects. -```bash -pip install -e . -``` +### Execution Control behavior + +- Update plan/apply stages in `core/domain/execution_control_plan.py` and + `core/domain/execution_control_apply.py`. +- Preserve `ControlSchedulingObligation` as non-canonical output. ---- +### Public API exports and docs -## Testing +- Update `tradingchassis_core/__init__.py` for intentional public exports only. +- Sync docs in `README.md` and `docs/reference/public-api.md`. -Before submitting: +## Pull Request Checklist -- The `./scripts/check.sh` script must pass -- All backtests must complete successfully and produce result artifacts +- [ ] Package remains Core-only and deterministic. +- [ ] Public API changes are intentional and tested. +- [ ] Quickstart still runs via public imports. +- [ ] `python -m pytest -q` passes. +- [ ] `python -m build` succeeds. +- [ ] README/docs/changelog updated to match behavior. diff --git a/README.md b/README.md index 5d98c01..3428744 100644 --- a/README.md +++ b/README.md @@ -1,192 +1,249 @@ -# TradingChassis — Core - -![CI](https://github.com/TradingChassis/core/actions/workflows/tests.yaml/badge.svg) -![Python](https://img.shields.io/badge/python-3.11+-blue) -![License](https://img.shields.io/badge/license-MIT-green) - -Deterministic semantic Core library for TradingChassis. - -This repository provides the reusable Core package (`tradingchassis_core`) that defines -event-driven processing semantics, state derivation boundaries, strategy interfaces, risk policy -contracts, and execution control primitives. - ---- - -## Overview - -Core is a library, not a runtime shell. - -- Canonical processing model: Event Stream + Configuration -> derived State -- Explicit Strategy, Risk Engine, and Execution Control boundaries -- Deterministic behavior under identical Event Stream and Configuration -- Runtime environments consume this package and provide integration wiring - ---- - -## What Core is - -Core provides: - -- semantic/domain types and value models -- processing-order and state-derivation primitives -- risk-policy interfaces and enforcement boundaries -- execution-control abstractions -- strategy interfaces for emitting Intents from derived State - ---- - -## What Core is not - -Core does not provide: - -- local/cluster runtime entrypoints -- Kubernetes or Argo orchestration -- runtime image/deployment plumbing -- full runtime ingress, replay, or storage infrastructure - -Those responsibilities live in Core Runtime (`core-runtime`). - ---- - -## Current semantic status - -The transitional semantic upgrade milestone is closed. - -Core remains the canonical semantic library, and current runtime usage focuses on canonical -`MarketEvent`, `OrderSubmittedEvent`, and `ControlTimeEvent` paths. - -Compatibility/deferred runtime capabilities still exist and are intentionally not described here as -fully complete canonical coverage. - ---- - -## Key concepts - -Terminology follows `docs/docs/00-guides/terminology.md`: - -- Event -- Event Stream -- Processing Order -- Configuration -- State -- Intent -- Risk Engine -- Queue -- Queue Processing -- Execution Control -- Order -- Core -- Runtime -- Venue Adapter - ---- - -## Canonical boundary - -Core guarantees deterministic semantics and reusable contracts. - -Runtimes supply environment-specific concerns such as: - -- ingress wiring -- adapter implementations -- orchestration entrypoints -- persistence/replay infrastructure - ---- - -## Canonical vs compatibility artifacts - -At the Core level: +# TradingChassis Core + +`tradingchassis_core` is the stable deterministic trading decision kernel +for TradingChassis: an Event-step engine that applies ordered canonical Events +(the Event Stream under Processing Order and Configuration) +and produces `CoreStepResult` outputs—including Strategy-generated and +candidate Intents, optional `dispatchable_intents`, and optional +Control Scheduling Obligation output. It does not perform Venue I/O, +Execution (adapter-side dispatch), or Runtime orchestration. + +**What it is:** a shared library for the decision path only—canonical Event in, +deterministic Strategy / Risk Engine / Execution Control processing, Intents and +Execution Control outputs out. + +**What it is not:** a one-off Backtesting script, a Venue +connector, a Live or Kubernetes Runtime, or anything that performs external +dispatch. The same Core is meant to stay stable while local Research, Backtesting, +simulation, Live trading, Venue Adapters, and infrastructure around you change. + +> Terminology: Definitions and related terms match the +> [`canonical terminology`](https://tradingchassis.github.io/docs/latest/00-guides/terminology/). +> In-repo pointers: [`core/docs/README.md`](docs/README.md) and +> [`core/docs/code-map/core-pipeline-map.md`](docs/code-map/core-pipeline-map.md). + +## Why it is relevant + +Trading systems often drift when Backtesting logic, Live logic, policy limits, and +Strategy throttling are implemented in different places. TradingChassis Core +centralizes deterministic decision semantics—State reduction, Strategy +evaluation, Risk Engine (policy) admission, and Execution Control +(planning apply over reconciled Intents)—in one library. Runtime +environments (Backtesting, Live, Research tooling, Kubernetes-backed +deployments, different Venue Adapters) may change; Core should not. + +A typical notebook or one-off Backtesting script inlines feed handling, Strategy rules, +Risk Engine (policy) checks, and how orders are sent in one place. That is fast to sketch but +tends to fork: the Live path reimplements similar ideas with different bugs and +timing. Core keeps the decision kernel in one place: Runtimes normalize inputs into +canonical Events, invoke Core, and perform Execution and dispatch outside Core using +`CoreStepResult`; Strategy, Risk Engine, +and Execution Control semantics stay identical across those Runtimes when the +Event Stream and Configuration match. + +## What it gives you + +| What you get | Why it matters | +| --- | --- | +| One deterministic Core pipeline | Same Event-step path for reduction → evaluation → candidates → Risk Engine → Execution Control apply | +| Canonical Event input model (`EventStreamEntry`) | Aligns with Event Stream + Processing Order; State is `f(Event Stream, Configuration)` | +| Strategy output as Intents | Internal, order/Venue-agnostic commands before Venue Adapter-specific shapes | +| Risk Engine separated from Execution Control | Risk Engine (policy) vs Queue / scheduling / rate-aware presentation split, as in the intent pipeline (Strategy → Risk → Queue → Adapter) | +| `dispatchable_intents` + optional Control Scheduling Obligation | Runtime performs Execution and may inject canonical `ControlTimeEvent` when a **rate-limit** obligation is realized ([`docs/flows/control-time-and-scheduling.md`](docs/flows/control-time-and-scheduling.md)); inflight deferral does not emit that obligation by default | + +## Control time and scheduling (Core) + +`ControlSchedulingObligation` is a **non-canonical**, time-dependent hint produced +when Execution Control **apply** defers for **rate limits**. **Inflight** gating is +**feedback-dependent** and does not, by default, produce this obligation; queued +work is reconsidered after canonical execution Events update state. Runtimes must +not flush Core queues outside the normal `run_core_step` / Execution Control apply +path. See [`docs/flows/control-time-and-scheduling.md`](docs/flows/control-time-and-scheduling.md). +| Runtime-independent package | Test trading semantics without production I/O; explicit ownership boundary | +| Shared kernel across environments | Serious Backtesting and Live parity for the decision engine—no secondary copy of Strategy/Risk Engine/Execution Control code elsewhere | + +In short: one pipeline, canonical Events, Intents inside Core, policy vs Execution +Control split, dispatchable Intents plus optional Control Scheduling Obligation for +the Runtime, and a boundary that makes parity and testing practical—not a second +copy of decision logic per environment. + +## Why it matters for trading + +The gap between tested behavior and Live trading behavior can dominate outcomes. **Backtesting** +is only a reliable guide if the **same** Core decision logic—Strategy, +Risk Engine, Execution Control—can drive Live when the Event Stream and +Configuration are comparable. Deterministic Core logic driven by canonical Events +makes that logic reproducible and unit-testable without duplicating it in each +Runtime. + +This package does **not** guarantee profitable trading, perfect Backtesting/Live +equality, or identical fills. It **does** remove a major class of drift: the +decision engine itself. Wall-clock scheduling, Venue behavior, Venue Adapter +mapping, latency, liquidity, market-data quality, and infrastructure failure modes +stay in the Runtime, Venue Adapter, and Venue—not in Core. + +## How it fits into a full system + +Backtesting Runtimes, Live Runtimes, and local Research or simulation harnesses can +all feed the **same** Core: they normalize feeds, timestamps, and control semantics +into canonical Events, build `EventStreamEntry` sequences, and call the same +`run_core_step` / reduction APIs. Core always returns the same contract +(`CoreStepResult`) for a given step; each Runtime owns environment-specific +Execution, scheduling glue, and Control-Time Event injection when a Control Scheduling Obligation is realized. + +```mermaid +flowchart TB + R1["Runtime:
canonical Event"] --> Entry["EventStreamEntry:
canonical Event + ProcessingPosition"] + Entry --> Core["TradingChassis Core:
CoreStep / CoreWakeupStep"] + Core --> Result["CoreStepResult:
dispatchable Intents + Control Scheduling Obligation"] + Result --> R2["Runtime:
dispatch / scheduling / I/O"] +``` -- Canonical artifacts are semantic models and deterministic processing contracts -- Compatibility artifacts are transitional runtime-facing paths maintained for migration parity +Core never replaces the Runtime: the Runtime is responsible for feeding canonical +Events and for turning `dispatchable_intents` into Venue traffic (and for everything +Kubernetes, credentials, and operations-related). What stays stable is the Core +pipeline and contracts; what varies by design is Runtime choice, Venue Adapter, +Venue, and deployment. -The runtime-level capability matrix is documented in `core-runtime/README.md`. +## Backtesting and Live parity ---- +Core is designed to reduce decision-logic drift between Backtesting +and Live: the same canonical Event + `run_core_step` / reduction APIs +can drive both worlds when each Runtime constructs comparable `EventStreamEntry` +sequences under the same Configuration. Normalizing feeds, timestamps, and +control semantics before they enter Core narrows unnecessary divergence. -## Package and import names +Core does not remove every simulation-vs-production gap. Individual Venue +behavior, latency, fills and liquidity, market-data quality, Venue Adapter +behavior, Runtime scheduling, and infrastructure failure modes can still +differ and must be modeled outside Core. What Core removes is a major +source of mismatch—duplicating and subtly diverging Strategy/Risk Engine/ +Execution Control itself. -- Human-facing concept name: Core -- Distribution/project name: `tradingchassis-core` -- Python import package: `tradingchassis_core` +## When to use `tradingchassis_core` -Install: +- Building an internal trading system where Backtesting and Live should share decision semantics. +- Wanting a deterministic Strategy / Risk Engine / Execution Control kernel. +- Separating trading semantics from Venue Adapters, I/O, and Kubernetes wiring. +- Testing decisions and Intents without full Backtesting or Live machinery. +- Sharing one decision path across simulation and production. -```bash -python -m pip install -e . -``` +## When not to use `tradingchassis_core` -Install with dev extras: +- You only need a one-off Backtesting notebook experiment. +- You want a complete Venue connector or turnkey Live implementation. +- You expect this package to ship a full Kubernetes Runtime, deployment manifests, or production operations. +- You expect Core to execute orders, talk to Venues, replace Venue Adapters, or perform external dispatch. -```bash -python -m pip install -e ".[dev]" -``` +## Full pipeline ---- - -## Repository structure +Internal processing pipeline, in sequential order: ```text -tradingchassis_core/ Core package root -tradingchassis_core/core/ Domain and semantic primitives -tradingchassis_core/strategies/ Strategy interfaces and config -tests/ Core test suites -scripts/ Developer helper scripts +Runtime reduces to canonical Events + + -> process_event_entry / process_canonical_event + -> Strategy evaluation + -> generated Intents + -> candidate records + -> dominance / reconciliation + -> Risk Engine (policy) + -> Execution Control plan/apply + -> CoreStepResult.dispatchable_intents + +Runtime dispatches Intents into Orders ``` ---- - -## Development setup +## Input / Core / Output / Not Owned By Core -Requirements: +- Input: `EventStreamEntry` values with canonical Events and Event Stream position. +- Core does: deterministic reduction, Strategy evaluation boundary, candidate + merge/dominance, Risk Engine (policy), Execution Control planning/apply. +- Output: `CoreStepResult` with generated/candidate Intents, optional + `dispatchable_intents`, and optional `control_scheduling_obligation`. +- Not owned by Core: raw market/feed I/O, Venue Adapters, external dispatch, + credentials/environment wiring, Runtime orchestration, Kubernetes/deployment. -- Python 3.11+ +## Quickstart -Recommended local setup: +Run the quickstart ```bash -python -m pip install -e ".[dev]" +python examples/core_step_quickstart.py ``` ---- - -## Test commands - -From the `core` repository root: - -```bash -python -m pytest +or minimal shape: + +```python +import tradingchassis_core as tc + +state = tc.StrategyState(event_bus=tc.NullEventBus()) +result = tc.run_core_step( + state, + tc.EventStreamEntry( + position=tc.ProcessingPosition(index=0), + event=tc.ControlTimeEvent( + ts_ns_local_control=1_000, + reason="scheduled_control_recheck", + due_ts_ns_local=1_000, + realized_ts_ns_local=1_000, + obligation_reason="rate_limit", + obligation_due_ts_ns_local=1_000, + runtime_correlation=None, + ), + ), +) +print(result.generated_intents, result.dispatchable_intents) +# Expected: () () — no Strategy or Risk Engine/Execution Control path in this snippet. ``` -From a monorepo parent containing `core/`: +See `examples/core_step_quickstart.py` for a full runnable walkthrough. -```bash -python -m pytest -q core/tests -``` +## Public Entrypoints ---- +| Entrypoint | Purpose | +| --- | --- | +| `run_core_step` | One-entry deterministic reduce/evaluate/decide/apply step | +| `run_core_wakeup_reduction` | Multi-entry reduction phase for one wakeup (no per-entry Strategy) | +| `run_core_wakeup_decision` | Wakeup-level candidate/Risk Engine/Execution Control decision phase | +| `run_core_wakeup_step` | Reduce all entries, evaluate Strategy once, then one decision pass | +| `process_event_entry` | Reduce one `EventStreamEntry` into `StrategyState` | +| `process_canonical_event` | Reduce one canonical Event into `StrategyState` | -## Relationship to Core Runtime -Core Runtime (`core-runtime`) provides runtime execution around Core, including: -- local hftbacktest-backed execution entrypoints -- Argo/runtime orchestration entrypoints -- runtime configuration and environment wiring -- local output artifacts under `.runtime/local/results/` +## CoreWakeupStep semantics -Core provides the deterministic semantics those runtime paths consume. +CoreWakeupStep is not "parallel Event processing". +It is deterministic batch processing: the Runtime gives Core an ordered sequence of +canonical entries, and Core reduces them in that order before making one decision. ---- +- `run_core_step` handles one `EventStreamEntry`. +- `run_core_wakeup_step` handles an ordered batch of `EventStreamEntry` values. +- Runtime is responsible for normalizing and ordering simultaneous raw inputs. +- Core reduces all wakeup entries in order, evaluates Strategy once on the final state, + then runs Policy Admission and ExecutionControl once. +- Runtime dispatches after the returned `CoreStepResult`. -## Documentation index +## Ownership Boundary -- Terminology source of truth: `docs/docs/00-guides/terminology.md` -- Runtime capabilities and entrypoints: `core-runtime/README.md` +| Core owns | Runtime owns | +| --- | --- | +| canonical models/contracts | raw I/O and feed adapters | +| State reduction and ordering | Venue Adapters and transport | +| Strategy evaluation boundary | adapter-side Execution | +| candidate Intents and reconciliation | credentials/env wiring | +| Risk Engine (policy) | Backtesting/Live orchestration | +| Execution Control | Kubernetes/deployment | +| `CoreStepResult` decision contract | Runtime lifecycle glue | ---- +## Developer Commands -## License and versioning +From root: -MIT licensed. Versioning follows semantic versioning. +```bash +python -m pip install -e ".[dev]" +python examples/core_step_quickstart.py +./scripts/check.sh +python -m build +``` diff --git a/SECURITY.md b/SECURITY.md index 9e224c4..5228104 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,72 +1,57 @@ # Security Policy -## Supported Versions +## Supported Baseline -Only the latest version on the `main` branch is actively maintained. +The supported baseline is the clean standalone Core package line (`0.1.x` and +forward on the active mainline branch). -Older commits and historical states of the repository may not receive security updates or patches. - ---- +Older historical commits may not receive fixes. ## Reporting a Vulnerability -If you discover a security vulnerability, please **do not open a public GitHub issue**. - -Instead, report it responsibly via: - -- GitHub Security Advisories -- Direct contact with the repository owner (if necessary) - -When submitting a report, please include: - -- A clear description of the vulnerability -- Steps to reproduce (if applicable) -- Potential impact and affected components -- Any suggested mitigation or fix - -Valid reports will be acknowledged in a timely manner and handled through responsible disclosure. - ---- - -## Security Scope - -This repository provides: +Do not report vulnerabilities in public issues. -- Deterministic backtesting architecture -- Risk-aware execution simulation -- Event-driven domain modeling +Use a private security advisory workflow if available for this repository, or +contact project maintainers through the configured private channel. -It does **not** currently provide production-grade live trading infrastructure. +Include: -Live exchange connectivity is under development and not feature-complete. +- affected component(s) +- reproduction details and impact +- suggested mitigations (if known) ---- +## Scope -## Dependency Security +This policy covers the Core package in this repository, including: -- Dependencies are explicitly defined -- Python version is pinned (3.11.x) -- External libraries should be kept up to date +- canonical Event and Intent contracts +- deterministic CoreStep/CoreWakeupStep decision pipeline +- package integrity and dependency usage in `tradingchassis_core` -Security-related dependency updates are prioritized. +## Secrets and Credentials Policy ---- +Never commit live secrets or account-sensitive data, including: -## Responsible Usage +- API keys and Venue credentials +- account identifiers tied to real accounts +- private trading data dumps -This code is intended for research and controlled environments. +Tests and documentation examples must use synthetic or non-sensitive data only. -Users are responsible for: +## Runtime and Trading Caveat -- Secure handling of API credentials -- Secure deployment of live trading components -- Validation of risk configurations +- TradingChassis Core is a library and does not guarantee safe live trading by + itself. +- Runtime orchestration, Venue behavior, and deployment hardening remain outside + this package scope and require separate validation. -This repository does not assume liability for financial losses -resulting from misuse or incorrect configuration. +## No Financial Performance Guarantee ---- +This package provides deterministic software behavior, not financial advice or +performance guarantees. -## Disclosure Policy +## Dependency Vulnerability Handling -Please allow reasonable time for investigation and remediation before public disclosure of any reported vulnerabilities. +- Keep dependencies minimal and pinned by compatible ranges. +- Review dependency advisories and patch vulnerable versions promptly. +- Prefer removing unused dependencies over adding new tooling. diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index c55ee60..0000000 --- a/docs/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# Core Docs Contract Index v1 - -This directory contains implementation-facing contracts and snapshots for `core`. - -The main `docs` repository remains the semantic source of truth for architecture -and terminology. Documents in `core/docs` must not contradict main docs -semantics. - -## Current documents - -- **[stable]** [Core Stable Contract v1](core-stable-contract-v1.md) - Stable snapshot of currently implemented and tested `core` v1 semantic - guarantees and boundaries. - -- **[boundary]** [Runtime-to-CoreConfiguration Contract Boundary v1](runtime-to-coreconfiguration-contract-v1.md) - Boundary contract for runtime-owned mapping into `CoreConfiguration` - before calling `core` canonical processing APIs; mapping is implemented in - `core-runtime`, while this page defines boundary expectations. - -- **[boundary/deferred]** [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md) - Boundary contract freezing eligibility requirements for future canonical - runtime execution feedback emission (including `FillEvent`), while preserving - current compatibility projection behavior. - -- **[boundary/source-contract]** [Runtime/Adapter Execution Feedback Source Contract v1](runtime-adapter-execution-feedback-source-contract-v1.md) - Source-authority boundary contract defining eligibility, authority, ordering, - and no-double-counting requirements before canonical `FillEvent` ingress. - -- **[boundary/implemented-transition]** [OrderSubmittedEvent / Dispatch Boundary Contract v1](order-submitted-event-contract-v1.md) - Implemented-transition boundary contract for dispatch-time canonical - order-entry semantics and coexistence constraints around `Submitted` authority. - -- **[boundary/implemented-transition]** [Control-Time Event Contract v1](control-time-event-contract-v1.md) - Implemented-transition boundary contract for canonical Control-Time Event - realization semantics and coexistence constraints with compatibility wakeups. - -- **[boundary/compatibility-map]** [Post-Submission Lifecycle Compatibility Map v1](post-submission-lifecycle-compatibility-map-v1.md) - Docs-only authority split map freezing canonical `Submitted` entry via - `OrderSubmittedEvent` and compatibility-governed post-submission lifecycle - progression until execution-feedback source gates are satisfied. - -- **[boundary/model]** [Venue Adapter Capability Model v1](venue-adapter-capability-model-v1.md) - Docs-only venue-agnostic capability model defining adapter/runtime source - capability categories and semantic authority classifications without API - implementation or runtime behavior changes. - -- **[boundary/deferred-abstraction]** [ProcessingContext / EventStreamCursor Contract v1](processing-context-event-stream-cursor-contract-v1.md) - Docs-only boundary contract defining ownership and responsibility split for - runtime-owned `EventStreamCursor` and deferred `ProcessingContext` - abstraction work, without introducing behavior changes in this slice. - -- **[boundary/characterization]** [EventStreamCursor Characterization Note v1](event-stream-cursor-characterization-v1.md) - Read-only characterization of current runtime `EventStreamCursor` behavior - and invariants, without introducing implementation or behavior change. - -- **[milestone/closure]** [Semantic Core Upgrade Milestone Closure v1](semantic-core-upgrade-milestone-closure-v1.md) - Docs-only closure snapshot of satisfied, transitional, and deferred semantic - implementation status and current usability statements for `core` and - `core-runtime`. - -- **[planning/naming]** [Package Rename Stage 0 Decision v1](package-rename-stage-0-decision-v1.md) - Stage 0 naming decision record defining final naming targets, compatibility - strategy, and first implementation slice for the package-rename track. - -- **[historical/dev-log]** [CoreConfiguration to Positioned Market Contract](coreconfiguration-positioned-market-contract.md) - Historical closure contract for positioned canonical `MarketEvent` - configuration-path and validation behavior in `core`. - -## Deferred / not implemented here - -- Runtime mapping implementation details. -- Queue/rate reducer migration and full control-time authority migration. -- FillEvent runtime ingress and source authority rollout. -- Post-submission execution feedback canonicalization. -- `OrderStateEvent` canonicalization. -- Replay/storage/`ProcessingContext` and full runtime stream integration. diff --git a/docs/code-map/core-pipeline-map.md b/docs/code-map/core-pipeline-map.md new file mode 100644 index 0000000..cfd4ed3 --- /dev/null +++ b/docs/code-map/core-pipeline-map.md @@ -0,0 +1,55 @@ +# Core Pipeline Map + +This map captures the only supported deterministic decision pipeline for +TradingChassis Core. + +## Step-by-step flow + +1. `EventStreamEntry` arrives with `ProcessingPosition`. +2. `process_event_entry` forwards to `process_canonical_event`. +3. Canonical reducer mutates `StrategyState` deterministically. +4. Strategy evaluation produces generated Intents. +5. Candidate records are built and reconciled/dominated. +6. Risk Engine (policy) accepts/rejects generated candidates. +7. Execution Control plan/apply computes Queue/dispatch/scheduling outputs. +8. `CoreStepResult` returns `dispatchable_intents` and optional + `control_scheduling_obligation` (non-canonical; **rate-limit** deferral only + in the current slice—see `../flows/control-time-and-scheduling.md`). +9. Runtime can dispatch later and inject further canonical Events (including + `ControlTimeEvent` when an obligation is realized); Core does not perform + external dispatch or mutate queues outside this pipeline. + +## Core APIs + +- Single-entry flow: `run_core_step` +- Wakeup reduction: `run_core_wakeup_reduction` +- Wakeup decision/apply: `run_core_wakeup_decision` +- Wakeup convenience wrapper: `run_core_wakeup_step` + +## Determinism notes + +- Processing Order monotonicity is enforced by `ProcessingPosition`. +- Core logic is side-effect-safe apart from deterministic state mutation. +- Runtime adapters and external dispatch concerns are outside Core. + + +## CoreWakeupStep batch semantics + +`CoreWakeupStep` is not "parallel Event processing". +It is deterministic batch processing: the Runtime gives Core an ordered sequence of +canonical `EventStreamEntry` values, and Core reduces them in that order before making +one decision. + +Wakeup flow: + +1. Runtime supplies an ordered batch of `EventStreamEntry` values. +2. `run_core_wakeup_reduction` calls `process_event_entry` for each entry in order. +3. `CoreWakeupStrategyEvaluator.evaluate` runs **once** on the fully reduced state + (`CoreWakeupStrategyContext` carries all entries). +4. `run_core_wakeup_decision` snapshots queued intents once, combines generated + queued + once, applies dominance/reconciliation once, Policy Admission once, and + ExecutionControl plan/apply once. +5. `CoreStepResult.dispatchable_intents` is returned; Runtime dispatches later. + +`run_core_step` remains single-entry: one reduction, one step-level Strategy evaluation, +one decision pass. diff --git a/docs/code-map/repository-map.md b/docs/code-map/repository-map.md new file mode 100644 index 0000000..d050461 --- /dev/null +++ b/docs/code-map/repository-map.md @@ -0,0 +1,38 @@ +# Repository Map + +High-level map for the standalone Core package. + +## Package layout + +- `tradingchassis_core/__init__.py`: public package boundary exports +- `tradingchassis_core/core/domain/`: canonical contracts and deterministic + pipeline orchestration +- `tradingchassis_core/core/risk/`: policy-only Risk Engine evaluator/config +- `tradingchassis_core/core/execution_control/`: Execution Control primitives +- `tradingchassis_core/core/events/`: internal Event bus/sink utilities + +## Tests and examples + +- `tests/semantics/`: focused contract and deterministic behavior tests +- `examples/core_step_quickstart.py`: public-import quickstart + +## Top-level package docs and metadata + +- `README.md`: package front door +- `CHANGELOG.md`: clean baseline changelog +- `CONTRIBUTING.md`: development and architecture rules +- `SECURITY.md`: vulnerability handling and scope policy +- `pyproject.toml`: build and tooling configuration + +## Boundary matrix + +Core owns: + +- canonical Events and Processing Order contracts +- deterministic reduction and step decisions +- Intent candidate, Risk Engine (policy), Execution Control outputs + +Core does not own: + +- Runtime orchestration, Venue Adapters, I/O, deployment +- dispatch lifecycle beyond `CoreStepResult` outputs diff --git a/docs/control-time-event-contract-v1.md b/docs/control-time-event-contract-v1.md deleted file mode 100644 index b3924ab..0000000 --- a/docs/control-time-event-contract-v1.md +++ /dev/null @@ -1,213 +0,0 @@ -# Control-Time Event Contract v1 - ---- - -## Purpose and scope - -This document defines an implementation-facing boundary contract snapshot for -the Control-Time Event transition boundary across `core` and runtime after -initial model/taxonomy/boundary/runtime injection slices. - -This is a docs-contract reconciliation slice only: - -- it does not change runtime wakeup behavior; -- it does not modify `ExecutionControl` behavior; -- it does not modify queue/rate/inflight behavior; -- it does not introduce periodic control ticks. - ---- - -## Semantic source of truth and precedence - -`CTEC-01` - Main `docs` repository remains the semantic source of truth for -Event semantics, Event Stream, Processing Order, Control Events, and -Control-Time Event behavior. - -`CTEC-02` - This document is a `core` implementation boundary contract snapshot -for future Control-Time Event canonicalization boundaries. It does not redefine -architecture semantics. - -`CTEC-03` - Existing `core` implementation snapshot semantics remain governed by -[Core Stable Contract v1](core-stable-contract-v1.md). This contract records -the current implemented transition slice and what remains deferred. - -Normative semantic sources: - -- `docs/docs/00-guides/terminology.md` -- `docs/docs/20-concepts/event-model.md` -- `docs/docs/20-concepts/time-model.md` -- `docs/docs/20-concepts/queue-processing.md` -- `docs/docs/20-concepts/invariants.md` - ---- - -## Classification - -`CTEC-04` - `ControlSchedulingObligation` remains a non-canonical runtime-facing -helper in this contract snapshot. - -`CTEC-05` - `GateDecision.next_send_ts_ns_local` remains a compatibility -scheduling surface in this contract snapshot. - -`CTEC-06` - A Control-Time Event is a canonical Control Event only once Runtime -realizes a previously derived control scheduling obligation and injects the -event into the Event Stream boundary. - -`CTEC-06a` - Current implemented transition slice includes: - -- `ControlTimeEvent` model in `core` domain types; -- taxonomy mapping as canonical `CONTROL` category candidate; -- canonical boundary acceptance via `process_event_entry` / `process_canonical_event`; -- `StrategyState.apply_control_time_event` as a no-op reducer for this slice. - -`CTEC-07` - `EventBus` remains non-canonical transport/integration -infrastructure and is not a canonical Event Stream record. - -`CTEC-08` - Queued intents, inflight markers, and rate state remain -derived/internal state and are not canonical Events. - ---- - -## Runtime realization trigger - -`CTEC-09` - A canonical Control-Time Event may be emitted only when Runtime -realizes a previously derived scheduling obligation/deadline. - -`CTEC-10` - Realization is sparse and deadline-style; it is not a periodic tick -model. - -`CTEC-11` - A Control-Time Event must not be emitted merely because wall-clock -or simulation time passes without a derived obligation boundary. - -`CTEC-12` - `ExecutionControl` does not emit canonical Control-Time Events -directly in this contract snapshot. - -`CTEC-12a` - In the current HFT runtime transition slice, injection occurs only -when a scheduled deadline is realized (`next_send_ts_ns_local` is present and -runtime local time has reached/passed that deadline), and injection is ordered -after queued-intent pop and before the gate path. - ---- - -## Relationship to ControlSchedulingObligation - -`CTEC-13` - Control scheduling obligations are derived by the current -core execution-control/risk path as non-canonical runtime-facing signals. - -`CTEC-14` - A Control scheduling obligation is not Event Stream input and -produces no canonical State Transition by itself. - -`CTEC-15` - A control scheduling obligation may request/suggest a future wakeup -or deadline (for example through compatibility scheduling surfaces). - -`CTEC-16` - Runtime owns future realization of the obligation into canonical -Control-Time Event stream input. - ---- - -## Minimal future Control-Time Event shape - -`CTEC-17` - ProcessingPosition authority remains carried by -`EventStreamEntry.position`, not embedded as an inline event payload field. - -`CTEC-18` - The future Control-Time Event payload should include at least: - -- `ts_ns_local_control` -- `reason` -- `due_ts_ns_local` or `realized_ts_ns_local` (when applicable) -- optional obligation/correlation metadata - -`CTEC-19` - Control-Time Event payload must not introduce market/order/fill -semantic fields. - -`CTEC-20` - Control-Time Event payload must not encode direct queue mutation -commands/payloads. - ---- - -## ProcessingPosition policy - -`CTEC-21` - Control-Time Event acceptance ordering must use the global canonical -ProcessingPosition sequence shared with other canonical candidates, including -`MarketEvent` and `OrderSubmittedEvent`. - -`CTEC-22` - Category-local canonical counters are not allowed. - -`CTEC-23` - Processing order authority must not be timestamp-derived. - ---- - -## Reducer and processing semantics boundary - -`CTEC-24` - Future Control-Time Event processing should allow deterministic -queue/rate/inflight derived processing to run at the canonical event boundary. - -`CTEC-25` - Current implemented reducer semantics are intentionally no-op for -ControlTimeEvent in this transition slice. - -`CTEC-26` - Queue Processing remains deterministic event processing, not -independent wall-clock mutation. - ---- - -## Coexistence with current compatibility behavior - -`CTEC-27` - `next_send_ts_ns_local` remains the current compatibility -scheduling/wakeup surface during transition. - -`CTEC-28` - Existing runtime timeout/wakeup behavior remains unchanged in this -contract snapshot. - -`CTEC-29` - Future implementation must avoid dual-authority ambiguity between -compatibility wakeup surfaces and canonical Control-Time Event stream authority. - -`CTEC-30` - `GateDecision` shape remains unchanged in this contract snapshot. - -`CTEC-30a` - Current runtime uses one global canonical ProcessingPosition -counter shared by `MarketEvent`, `OrderSubmittedEvent`, and `ControlTimeEvent`. - ---- - -## Explicitly prohibited behavior - -`CTEC-31` - Do not classify `ControlSchedulingObligation` as canonical Event. - -`CTEC-32` - Do not emit periodic control ticks. - -`CTEC-33` - Do not use Event Time as Processing Order authority. - -`CTEC-34` - Do not mutate queue/rate state outside canonical processing in -future strict-mode canonical behavior. - -`CTEC-35` - Do not use `EventBus` as canonical Event Stream. - ---- - -## Explicitly out of scope - -`CTEC-36` - Additional ControlTimeEvent model shape expansion beyond the current -implemented contract fields. - -`CTEC-37` - Further event taxonomy semantic changes beyond current -`ControlTimeEvent` canonical `CONTROL` mapping. - -`CTEC-38` - Runtime injection generalization beyond current scheduled-deadline -realization transition behavior. - -`CTEC-39` - Queue/rate reducer migration. - -`CTEC-40` - Replay/storage/`ProcessingContext`/`EventStreamCursor` -implementation. - -`CTEC-41` - `FillEvent` ingress implementation. - -`CTEC-42` - `OrderStateEvent` canonicalization. - ---- - -## Relationship to existing core contracts - -- [Core Stable Contract v1](core-stable-contract-v1.md) -- [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md) -- [OrderSubmittedEvent / Dispatch Boundary Contract v1](order-submitted-event-contract-v1.md) - diff --git a/docs/core-stable-contract-v1.md b/docs/core-stable-contract-v1.md deleted file mode 100644 index 78e4270..0000000 --- a/docs/core-stable-contract-v1.md +++ /dev/null @@ -1,201 +0,0 @@ -# Core Stable Contract v1 - ---- - -## Purpose and scope - -This page freezes the currently implemented and tested semantic kernel of `core` as a **stable implementation contract snapshot (v1)**. - -Repository boundary: - -- Semantic definitions (Event, Event Stream, Processing Order, Configuration, State, Intent, Order, etc.) live in the main `docs` repository and remain the semantic source of truth. -- This page is **implementation-facing** documentation for `core` and only claims what is currently implemented and tested in `core` v1. - -This page is intentionally narrow: - -- it documents what `core` v1 currently guarantees; -- it distinguishes implemented guarantees from deferred architecture concepts; -- it does not introduce new behavior. - -Historical provenance for the positioned market configuration closure is recorded in: - -- [CoreConfiguration to Positioned Market Contract](coreconfiguration-positioned-market-contract.md) - ---- - -## Normative sources and precedence - -`CSC-01` — Terminology and architecture concepts in the main `docs` repository remain the semantic source of truth. - -`CSC-02` — This page defines the **implementation snapshot contract** for current `core` v1. If architecture/concept docs describe broader target semantics not yet implemented in `core`, this page controls claims about current `core` behavior. - -`CSC-03` — Dev logs remain historical decision trails and are not the stable contract surface. - ---- - -## Canonical boundary APIs (v1) - -`CSC-04` — `core` v1 currently guarantees a minimal canonical processing boundary through: - -- `process_canonical_event` -- `process_event_entry` -- `fold_event_stream_entries` - -`CSC-05` — These APIs define the currently stabilized canonical ingestion/fold surface in `core` v1. They are not a full Event Stream runtime, storage, or replay orchestration API. - ---- - -## Canonical event candidate set (v1) - -`CSC-06` — `core` v1 currently guarantees the canonical event candidate set: - -- `MarketEvent` (market category candidate) -- `OrderSubmittedEvent` (intent-related category candidate) -- `FillEvent` (execution category candidate) -- `ControlTimeEvent` (control category candidate) - -`CSC-07` — Current canonical runtime wiring in this snapshot processes positioned canonical `MarketEvent`, `OrderSubmittedEvent`, and `ControlTimeEvent` through the canonical boundary. `ControlTimeEvent` runtime injection is currently realized only for scheduled-deadline wakeup realization and remains a transition slice (no queue/rate/control reducer migration implied). `FillEvent` remains a canonical execution candidate in `core`, while runtime `FillEvent` ingress remains deferred per [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md). - ---- - -## Non-canonical artifacts (v1) - -`CSC-08` — `OrderStateEvent` remains compatibility-only and is non-canonical at the canonical boundary. - -`CSC-09` — `DerivedFillEvent` remains a compatibility projection artifact and is non-canonical. - -`CSC-10` — Telemetry/observability records remain non-canonical, including: - -- `RiskDecisionEvent` -- `DerivedPnLEvent` -- `ExposureDerivedEvent` -- `OrderStateTransitionEvent` - -`CSC-11` — `GateDecision` remains compatibility/non-canonical. - -`CSC-12` — `ControlSchedulingObligation` remains a non-canonical runtime-facing helper, not a canonical Event. - -`CSC-13` — `EventBus` remains transport/integration infrastructure, not a canonical Event Stream record. - ---- - -## ProcessingPosition and Processing Order guarantees - -`CSC-14` — `ProcessingPosition` is the explicit boundary metadata for positioned canonical processing in `core` v1. - -`CSC-15` — For positioned canonical processing, position indexes are strictly monotonic; repeated or regressing indexes fail. - -`CSC-16` — Within the `core` package canonical boundary, processing position cursor advancement is boundary-owned behavior and remains guarded against out-of-boundary mutation patterns by current `core` semantics coverage. This clause does not claim repo-wide enforcement outside `core`. - -`CSC-17` — Positioned boundary acceptance order follows `ProcessingPosition` monotonicity, not event timestamp ordering. - ---- - -## EventStreamEntry contract - -`CSC-18` — `EventStreamEntry` v1 contract shape is: - -- `position` -- `event` - -`CSC-19` — `EventStreamEntry` contains no `configuration` field. - -`CSC-20` — Configuration remains call-level processing input, not entry-level payload shape. - ---- - -## CoreConfiguration contract - -`CSC-21` — `CoreConfiguration` v1 currently guarantees: - -- explicit `version`; -- explicit `payload`; -- stable derived `fingerprint`. - -`CSC-22` — Equivalent semantic payloads and version yield stable identity/fingerprint behavior; identity remains stable against source-payload mutation after construction. - -`CSC-23` — Canonical processing entry/fold APIs accept configuration as explicit call-level input (`CoreConfiguration | None`) and reject non-`CoreConfiguration` objects. - ---- - -## Positioned MarketEvent metadata contract - -`CSC-24` — For positioned canonical `MarketEvent` processing, `core` v1 consumes instrument metadata from: - -- `payload.market.instruments..tick_size` -- `payload.market.instruments..lot_size` -- `payload.market.instruments..contract_size` - -`CSC-25` — Positioned canonical market processing is explicit-or-fail for missing/invalid required configuration path or values. - -`CSC-26` — Positioned canonical market path has no implicit defaults for these required fields. - ---- - -## Fold and minimal replay contract (v1) - -`CSC-27` — `fold_event_stream_entries` is a deterministic fold utility over caller-provided ordered `EventStreamEntry` values. - -`CSC-28` — `fold_event_stream_entries` in `core` v1 is not a full replay engine, not Event Stream storage, and not runtime orchestration. - ---- - -## Compatibility boundaries preserved - -`CSC-29` — Unpositioned canonical market compatibility path remains preserved. - -`CSC-30` — Direct `StrategyState.update_market(...)` compatibility path remains preserved. - -`CSC-31` — `FillEvent` behavior remains preserved (including existing idempotence/no-op characteristics). - -`CSC-32` — `OrderStateEvent` compatibility reducer path remains preserved and non-canonical at canonical boundary. - ---- - -## Explicitly out of scope for core stable contract v1 - -`CSC-33` — Runtime/backtest-to-`CoreConfiguration` mapping implementation. - -`CSC-34` — Full Control-Time authority migration, including queue/rate reducer migration and broader runtime realization generalization beyond the current transition slice. - -`CSC-35` — Introduction of new canonical event categories or canonicalization of currently non-canonical artifacts. - -`CSC-36` — Event Stream storage layer. - -`CSC-37` — Full replay engine/runtime integration. - -`CSC-38` — `ProcessingContext` introduction and broader replay/storage-oriented -`EventStreamCursor` extraction in `core` scope. (A runtime-only -`EventStreamCursor` ordering helper exists in `core-runtime` and is outside -this `core` stable-contract scope.) - ---- - -## Change rubric - -`CSC-39` — **Breaking change** (v1 contract): any change that alters guaranteed behavior or contract shape in `CSC-04` through `CSC-38` (including canonical/non-canonical classification shifts, positioned market config semantics changes, cursor monotonicity behavior changes, or compatibility boundary behavior changes). - -`CSC-40` — **Additive change** (v1-compatible): new capability that does not alter existing guarantees and does not reinterpret current clause semantics. - -`CSC-41` — **Docs-only clarification**: wording refinement that improves precision without changing contract meaning or introducing new semantics. - ---- - -## Traceability matrix to existing semantics tests - -| Clause(s) | Contract statement (summary) | Existing semantics test anchors | -| --------- | ---------------------------- | ------------------------------- | -| `CSC-04`, `CSC-05` | Canonical boundary API surface and minimal scope | `core/tests/semantics/models/test_canonical_processing_boundary.py`, `core/tests/semantics/models/test_event_stream_entry_contract.py`, `core/tests/semantics/models/test_fold_event_stream_entries_contract.py` | -| `CSC-06`, `CSC-07` | Canonical candidate set is MarketEvent + OrderSubmittedEvent + FillEvent + ControlTimeEvent; current runtime wiring path includes MarketEvent + OrderSubmittedEvent + ControlTimeEvent while FillEvent ingress remains deferred | `core/tests/semantics/models/test_event_taxonomy_boundary.py`, `core/tests/semantics/models/test_canonical_processing_boundary.py`, `core-runtime/tests/runtime/test_strategy_runner_canonical_market_adoption.py` | -| `CSC-08` to `CSC-13` | Non-canonical classifications (compatibility/telemetry/control helper/transport) | `core/tests/semantics/models/test_event_taxonomy_boundary.py`, `core/tests/semantics/models/test_canonical_processing_boundary.py`, `core/tests/semantics/models/test_event_stream_entry_contract.py` | -| `CSC-14` to `CSC-17` | ProcessingPosition monotonic positioned boundary and cursor guarantees | `core/tests/semantics/models/test_canonical_processing_boundary.py`, `core/tests/semantics/models/test_event_stream_entry_contract.py`, `core/tests/semantics/models/test_fold_event_stream_entries_contract.py`, `core/tests/semantics/models/test_processing_position_cursor_ownership_guard.py` | -| `CSC-18` to `CSC-20` | EventStreamEntry shape and call-level configuration boundary | `core/tests/semantics/models/test_event_stream_entry_contract.py` | -| `CSC-21` to `CSC-23` | CoreConfiguration identity and call-level typing contract | `core/tests/semantics/models/test_core_configuration_contract.py`, `core/tests/semantics/models/test_fold_event_stream_entries_contract.py`, `core/tests/semantics/models/test_event_stream_entry_contract.py` | -| `CSC-24` to `CSC-26` | Positioned market metadata path and explicit-or-fail semantics | `core/tests/semantics/models/test_market_configuration_positioned_contract.py` | -| `CSC-27`, `CSC-28` | Deterministic fold minimal contract; not full replay/runtime/storage | `core/tests/semantics/models/test_fold_event_stream_entries_contract.py` | -| `CSC-29` to `CSC-32` | Compatibility boundaries preserved | `core/tests/semantics/models/test_market_configuration_positioned_contract.py`, `core/tests/semantics/models/test_canonical_processing_boundary.py` | - -Notes: - -- This matrix maps stable contract clauses to existing semantics coverage; it does not claim architecture-complete implementation. -- Deferred architecture concepts remain governed by their concept/architecture docs and are out of scope for this v1 implementation snapshot. diff --git a/docs/coreconfiguration-positioned-market-contract.md b/docs/coreconfiguration-positioned-market-contract.md deleted file mode 100644 index 58dca5b..0000000 --- a/docs/coreconfiguration-positioned-market-contract.md +++ /dev/null @@ -1,51 +0,0 @@ -# CoreConfiguration to Positioned Market Contract - ---- - -## Context - -Introduced strict `CoreConfiguration` consumption for the positioned canonical `MarketEvent` reduction path in `core`. - -This note freezes that behavior as an explicit closure contract. - ---- - -## Contract (Core-facing) - -For **positioned canonical** `MarketEvent` processing in `core`: - -1. `core` consumes deterministic semantic configuration **only** through `CoreConfiguration`. -2. Required payload path: - - `CoreConfiguration.payload["market"]["instruments"][instrument]` - -3. Required instrument fields: - - `tick_size` - - `lot_size` - - `contract_size` -4. Semantics are **explicit-or-fail**: - - missing `CoreConfiguration` fails; - - missing `market`/`instruments`/`instrument` path fails; - - missing required fields fails; - - invalid values (`None`, `bool`, non-numeric, non-finite, non-positive) fail. -5. Positioned canonical path has **no implicit defaults** for these fields. - ---- - -## Boundary and Compatibility Guarantees - -1. Validation for positioned canonical `MarketEvent` happens before: - - `ProcessingPosition` cursor advancement, and - - `MarketState` mutation. -2. **Unpositioned** canonical `MarketEvent` compatibility path remains unchanged. -3. Direct `StrategyState.update_market(...)` compatibility path remains unchanged. -4. `FillEvent` behavior remains unchanged. -5. `OrderStateEvent` remains compatibility-only (non-canonical at canonical boundary). - ---- - -## Runtime Boundary - -1. This contract does **not** introduce runtime/backtest JSON mapping in `core`. -2. Mapping from runtime/backtest config to `CoreConfiguration` is a **runtime responsibility**. -3. No `core-runtime` behavior or interfaces are changed by this contract note. diff --git a/docs/event-stream-cursor-characterization-v1.md b/docs/event-stream-cursor-characterization-v1.md deleted file mode 100644 index ceeec8a..0000000 --- a/docs/event-stream-cursor-characterization-v1.md +++ /dev/null @@ -1,168 +0,0 @@ -# EventStreamCursor Characterization Note v1 - ---- - -## Purpose and scope - -This note characterizes the **current runtime EventStreamCursor behavior** used -for canonical `ProcessingPosition` assignment and records invariants for future -runtime extraction/refinement work. - -This is read-only characterization/planning documentation: - -- it does not introduce new `EventStreamCursor` behavior; -- it does not implement `ProcessingContext`; -- it does not change runtime behavior; -- it does not change reducers or event taxonomy; -- it does not implement canonical `FillEvent` ingress; -- it does not add adapter APIs; -- it does not canonicalize `OrderStateEvent`; -- it does not change `DerivedFillEvent` behavior; -- it does not change snapshot ingestion behavior; -- it does not implement replay/storage/EventStream persistence. - -`ESCC-01` - Main `docs` remains the semantic source of truth for Event Stream and -Processing Order semantics. - -`ESCC-02` - This note is implementation-facing characterization only and does not -redefine existing contracts. - -`ESCC-03` - This note must remain consistent with: - -- [ProcessingContext / EventStreamCursor Contract v1](processing-context-event-stream-cursor-contract-v1.md) -- [Core Stable Contract v1](core-stable-contract-v1.md) -- [Venue Adapter Capability Model v1](venue-adapter-capability-model-v1.md) - ---- - -## Current runtime cursor behavior (characterized) - -Current behavior is implemented in -`core-runtime/core_runtime/backtest/engine/strategy_runner.py`. - -`ESCC-04` - Runtime runner owns an `EventStreamCursor` instance. - -`ESCC-05` - Cursor starts at index `0`. - -`ESCC-06` - `_process_canonical_event(...)` calls -`EventStreamCursor.attempt_position()` and constructs `EventStreamEntry` -with the returned `ProcessingPosition`. - -`ESCC-07` - Runner calls `process_event_entry(state, entry, configuration=core_cfg)` -for canonical boundary processing. - -`ESCC-08` - Cursor advances by exactly `+1` only after successful -`process_event_entry(...)` return via `commit_success(...)`. - -`ESCC-09` - If canonical boundary processing raises, cursor does not advance. - -`ESCC-10` - One global cursor sequence is shared by currently wired canonical -categories: - -- `MarketEvent` -- `OrderSubmittedEvent` -- `ControlTimeEvent` - -`ESCC-11` - Runtime canonical `FillEvent` ingress remains absent/deferred in the -current runner path. - -`ESCC-12` - Compatibility `rc == 3` snapshot branch -(`update_account` / `ingest_order_snapshots`) bypasses canonical -`EventStreamEntry` construction and does not define position-allocation authority. - -`ESCC-13` - Current ordering authority for canonical boundary acceptance remains -`ProcessingPosition`, not timestamp-derived ordering. - ---- - -## Invariants to preserve for extraction - -`ESCC-14` - First canonical event in a stream scope uses index `0`. - -`ESCC-15` - Position progression is monotone, global, and stepwise (`+1`) after -each successful canonical boundary processing call. - -`ESCC-16` - Failed canonical processing must not consume/advance position. - -`ESCC-17` - Cursor scope remains global across canonical categories; no -category-local counters. - -`ESCC-18` - Compatibility snapshot path (`rc == 3`) remains non-canonical and -does not allocate canonical positions in this phase. - -`ESCC-19` - `EventStreamEntry` remains minimal (`position`, `event`) and -config-free. - -`ESCC-20` - `CoreConfiguration` remains call-level processing input. - -`ESCC-21` - Core remains canonical boundary consumer/validator and is not runtime -position allocator. - ---- - -## Future EventStreamCursor extraction semantics (non-implemented) - -`ESCC-22` - Any future `EventStreamCursor` work remains runtime-owned and -ordering-only. - -`ESCC-23` - Recommended extraction model is reservation/commit semantics: - -- `attempt_position() -> position` -- `commit_success(position)` - -`ESCC-24` - Commit occurs only after successful `process_event_entry(...)` -completion. - -`ESCC-25` - No rollback-after-commit behavior is implied in this slice. - -`ESCC-26` - No reset/fork semantics within one canonical stream scope. - -`ESCC-27` - No category-local sequencing semantics. - -`ESCC-28` - No replay/storage/EventStream persistence semantics are implied by -cursor extraction characterization. - ---- - -## Characterization test anchors - -Existing tests that already anchor current behavior: - -- Shared global counter across canonical categories: - - `core-runtime/tests/runtime/test_strategy_runner_canonical_market_adoption.py::test_global_canonical_counter_shared_between_market_and_order_submitted` - - `core-runtime/tests/runtime/test_strategy_runner_canonical_market_adoption.py::test_global_canonical_counter_shared_with_control_time_market_and_submitted` -- No advance on failed canonical processing: - - `core-runtime/tests/runtime/test_strategy_runner_canonical_market_adoption.py::test_canonical_counter_increments_only_after_successful_canonical_processing` -- Compatibility `rc == 3` snapshot branch remains unchanged: - - `core-runtime/tests/runtime/test_strategy_runner_canonical_market_adoption.py::test_order_snapshot_branch_keeps_compatibility_path` - - `core-runtime/tests/runtime/test_hftbacktest_execution_feedback_probe.py::test_runner_contains_rc3_snapshot_branch` -- Configuration passed to `process_event_entry(...)`: - - `core-runtime/tests/runtime/test_strategy_runner_canonical_market_adoption.py::test_process_market_event_routes_through_event_entry_with_core_configuration` - -Coverage notes / potential direct-test gaps: - -`ESCC-29` - Existing tests strongly imply first-position-at-zero behavior, but no -dedicated runner test is named solely for that invariant. - -`ESCC-30` - Existing compatibility snapshot branch tests assert path usage, but no -dedicated assertion currently checks that runner cursor remains unchanged during -`rc == 3` processing alone. - ---- - -## Out of scope - -`ESCC-31` - Additional `EventStreamCursor` feature expansion beyond the current -runtime ordering helper behavior characterized here. - -`ESCC-32` - `ProcessingContext` implementation. - -`ESCC-33` - Adapter interface/API design. - -`ESCC-34` - Runtime canonical `FillEvent` ingress. - -`ESCC-35` - Lifecycle migration away from compatibility snapshot authority. - -`ESCC-36` - Replay/storage/EventStream persistence implementation. - ---- diff --git a/docs/flows/control-time-and-scheduling.md b/docs/flows/control-time-and-scheduling.md new file mode 100644 index 0000000..1257ce3 --- /dev/null +++ b/docs/flows/control-time-and-scheduling.md @@ -0,0 +1,68 @@ +# Control time and scheduling + +This note is the **Core package** source of truth for how non-canonical +`ControlSchedulingObligation` relates to canonical `ControlTimeEvent` input and +to Execution Control deferral. + +## Terms + +- **ControlSchedulingObligation** — Non-canonical Core output: a structured hint + that a **time-dependent** recheck may be useful. It is **not** part of the + canonical Event Stream and does not mutate `StrategyState`. +- **ControlTimeEvent** — Canonical **control** category Event. It becomes part of + deterministic history only after the **Runtime** injects it as + `EventStreamEntry` input (same ingestion path as other canonical Events). +- **Inflight** — Core-side **intent-operation** gating: a sendability / operation + slot (for example keyed by `client_order_id`) is occupied because an earlier + intent operation is still awaiting **canonical execution feedback**. This is + not the same as venue-side “order ownership”; Core models sendability for the + decision pipeline. +- **Rate-limit deferral** — Execution control blocks dispatch because the + configured **token / time budget** for orders or cancels is not yet available at + the apply clock (`now_ts_ns_local` in `CoreExecutionControlApplyContext`). +- **Inflight deferral** — Dispatch is blocked because **inflight** gating applies, + not because a rate-limit wake time is known ahead of time. + +## What Core emits today + +| Deferral kind | Time-dependent? | `ControlSchedulingObligation` by default? | Expected resolution | +| --- | --- | --- | --- | +| Rate limit | Yes | **Yes** (reason such as `rate_limit`) | Runtime may realize the obligation and inject `ControlTimeEvent`; the next `run_core_step` re-runs reduction → Strategy → … → Execution Control apply. | +| Inflight | No (feedback-dependent) | **No** | Later canonical **execution / lifecycle** Events (for example `OrderSubmittedEvent`, `OrderExecutionFeedbackEvent`, or `FillEvent`, depending on lifecycle) update `StrategyState` so a subsequent step can reconsider queued work. | + +**Not in scope for the current contract:** inflight timeout, wall-clock recovery, +or “synthetic” obligations for inflight-only waits. + +**Not implied:** every queued intent produces a scheduling obligation or a future +`ControlTimeEvent`. Obligations are for **rate-limit** rechecks in the current +Core slice. + +## Clean Core pipeline (unchanged) + +1. `EventStreamEntry` +2. `process_event_entry` / `process_canonical_event` +3. Strategy evaluator +4. generated intents +5. candidate records + dominance / reconciliation +6. policy admission +7. Execution Control plan / apply +8. `CoreStepResult.dispatchable_intents` and optional `control_scheduling_obligation` +9. Runtime performs venue dispatch and **injects** further canonical Events (including + any `ControlTimeEvent` realized from an obligation). + +Pure planning (`plan_execution_control_candidates`) does **not** emit obligations; +they are selected only in the mutable **apply** stage (`apply_execution_control_plan`). + +## Runtime ownership + +- Runtimes **must not** mutate Core queues (`StrategyState.queued_intents`, etc.) + directly outside the normal Core step / Execution Control apply path. +- Queue flush / sendability decisions remain **ExecutionControl-owned** inside + Core when `CoreExecutionControlApplyContext` is supplied to `run_core_step` / + wakeup APIs. + +## Further reading + +- [`reference/events-reference.md`](../reference/events-reference.md) +- [`code-map/core-pipeline-map.md`](../code-map/core-pipeline-map.md) +- Tests: `tests/semantics/test_control_time_scheduling_semantics.py` diff --git a/docs/how-to/add-canonical-event.md b/docs/how-to/add-canonical-event.md new file mode 100644 index 0000000..86f86b8 --- /dev/null +++ b/docs/how-to/add-canonical-event.md @@ -0,0 +1,16 @@ +# How To Add a Canonical Event + +1. Add or update the Pydantic Event model in + `tradingchassis_core/core/domain/types.py`. +2. Register the Event in `core/domain/event_model.py` canonical category mapping. +3. Add reducer handling in `core/domain/processing.py` within + `process_canonical_event`. +4. Add/update semantics tests under `tests/semantics/`. +5. Export the model from `tradingchassis_core/__init__.py` if part of public API. +6. Update `docs/reference/events-reference.md` and `docs/reference/public-api.md`. + +Rules: + +- Keep canonical processing deterministic. +- Do not introduce Venue Adapter or dispatch logic in reducers. +- Keep Pydantic contracts as source of truth. diff --git a/docs/how-to/update-core-step-pipeline.md b/docs/how-to/update-core-step-pipeline.md new file mode 100644 index 0000000..aed51cb --- /dev/null +++ b/docs/how-to/update-core-step-pipeline.md @@ -0,0 +1,35 @@ +# How To Update CoreStep Pipeline Behavior + +Core step orchestration lives in +`tradingchassis_core/core/domain/processing_step.py`. + +Recommended workflow: + +1. Start from `run_core_step` and identify which phase changes: + reduction, Strategy evaluation, reconciliation, Risk Engine (policy), or apply. +2. Keep stage boundaries explicit: + - reduction first + - Strategy generation second + - candidate reconciliation third + - Risk Engine (policy) fourth + - Execution Control plan/apply fifth +3. Preserve `CoreStepResult` as the public output contract. +4. Add or update tests in `tests/semantics/test_core_pipeline_clean.py`. +5. Confirm quickstart behavior still reflects the public contract. + +Guardrails: + +- No Runtime dispatch logic in Core pipeline code. +- No legacy compatibility contract restoration. +- Keep deterministic behavior and public API coherence. + + +## CoreWakeupStep changes + +When updating wakeup behavior: + +1. Keep `run_core_wakeup_reduction` as reduction-only (no per-entry Strategy calls). +2. Use `CoreWakeupStrategyEvaluator` and `wakeup_strategy_evaluator=` for batch evaluation. +3. Preserve one Policy Admission and one ExecutionControl apply per wakeup in + `run_core_wakeup_decision`. +4. Add tests in `tests/semantics/test_core_wakeup_final_state.py`. diff --git a/docs/how-to/update-policy-and-execution-control.md b/docs/how-to/update-policy-and-execution-control.md new file mode 100644 index 0000000..3a825be --- /dev/null +++ b/docs/how-to/update-policy-and-execution-control.md @@ -0,0 +1,33 @@ +# How To Update Risk Engine and Execution Control + +The Risk Engine (policy) and Execution Control are separate deterministic phases. + +## Risk Engine updates + +- Policy contract entrypoint: + `PolicyIntentEvaluator.evaluate_policy_intent(...)` +- Core integration: + `core/domain/policy_risk_decision.py` and `run_core_step` policy phase +- Built-in policy-only evaluator: + `core/risk/risk_engine.py` + +When updating Risk Engine policy behavior: + +1. Keep evaluation side-effect-free. +2. Return explicit accept/reject with reason. +3. Validate behavior with semantics tests. + +## Execution Control updates + +- Planning model: + `core/domain/execution_control_plan.py` +- Apply stage: + `core/domain/execution_control_apply.py` +- Runtime-facing non-canonical output: + `ControlSchedulingObligation` + +When updating Execution Control: + +1. Keep Queue/dispatchability decisions deterministic. +2. Preserve `CoreStepResult.dispatchable_intents` contract. +3. Use `ControlSchedulingObligation` for deferred control signals. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..433938a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,44 @@ +# TradingChassis Core Docs + +This documentation set describes the standalone clean Core package baseline. + +## Contents + +- `reference/public-api.md`: supported root exports and package boundary +- `reference/events-reference.md`: canonical Events and Intent contracts +- `flows/control-time-and-scheduling.md`: rate-limit vs inflight deferral and obligations +- `code-map/core-pipeline-map.md`: deterministic pipeline walkthrough +- `code-map/repository-map.md`: package layout and ownership map +- `how-to/add-canonical-event.md`: extending canonical Event contracts +- `how-to/update-core-step-pipeline.md`: changing CoreStep/CoreWakeupStep behavior +- `how-to/update-policy-and-execution-control.md`: changing Risk Engine / Execution Control behavior + +## Package Purpose + +TradingChassis Core is a deterministic trading decision engine library. It owns +canonical contracts, State reduction, and step-level decision outputs. + +## Clean Core Pipeline + +1. `EventStreamEntry` +2. `process_event_entry` / `process_canonical_event` +3. Strategy evaluation +4. generated Intents +5. candidate records + dominance/reconciliation +6. Risk Engine (policy) +7. Execution Control plan/apply +8. `CoreStepResult` outputs (`dispatchable_intents`, + optional `control_scheduling_obligation` for **rate-limit** deferral only—see + `flows/control-time-and-scheduling.md`) +9. Runtime dispatch happens later; Runtime injects canonical Events (including + optional `ControlTimeEvent` when an obligation is realized) + +## Contract source of truth + +Pydantic contract models in `tradingchassis_core/core/domain/types.py` are the +source of truth for canonical Event/Intent schemas. + +## Out of Scope + +- Runtime orchestration and Order lifecycle ownership +- Venue Adapters, Backtesting/Live I/O, external dispatch diff --git a/docs/order-submitted-event-contract-v1.md b/docs/order-submitted-event-contract-v1.md deleted file mode 100644 index c136f8c..0000000 --- a/docs/order-submitted-event-contract-v1.md +++ /dev/null @@ -1,228 +0,0 @@ -# OrderSubmittedEvent / Dispatch Boundary Contract v1 - ---- - -## Purpose and scope - -This document defines an implementation-facing boundary contract snapshot for the -dispatch-time canonical order-entry record `OrderSubmittedEvent` after initial -runtime wiring. - -This is a docs-contract reconciliation slice only: - -- it does not change runtime behavior; -- it does not change snapshot compatibility reducers; -- it does not canonicalize `OrderStateEvent`; -- it does not introduce `FillEvent` ingress; -- it does not change `mark_intent_sent`, `RiskEngine`, or Execution Control behavior. - ---- - -## Semantic source of truth and precedence - -`OSEC-01` - Main `docs` remains the semantic source of truth for Event semantics, -Intent pipeline semantics, Order lifecycle semantics, Event Stream, and -Processing Order. - -`OSEC-02` - This document is a `core` implementation boundary contract snapshot -for the dispatch-time Submitted boundary. It does not redefine architecture -semantics. - -`OSEC-03` - Existing `core` implementation snapshot semantics remain governed by -[Core Stable Contract v1](core-stable-contract-v1.md). This contract records the -implemented Submitted-boundary slice and its transition constraints; it does not -claim full order/execution lifecycle canonicalization. - -Normative semantic sources: - -- `docs/docs/00-guides/terminology.md` -- `docs/docs/10-architecture/intent-pipeline.md` -- `docs/docs/20-concepts/intent-lifecycle.md` -- `docs/docs/20-concepts/order-lifecycle.md` -- `docs/docs/20-concepts/event-model.md` -- `docs/docs/20-concepts/time-model.md` - ---- - -## Classification - -`OSEC-04` - `OrderSubmittedEvent` is classified as a canonical -**Intent-related Event**. - -`OSEC-05` - `OrderSubmittedEvent` is not an Execution Event in this contract. - -`OSEC-06` - Rationale: - -- Execution Events represent venue/simulated-venue execution feedback records. -- The Submitted boundary record captures a dispatch/submission pipeline outcome - from infrastructure processing. -- Therefore the semantic class is Intent-related Event, not Execution Event. - -`OSEC-07` - This classification is implemented in current `core` v1 candidate -taxonomy and canonical processing boundary behavior. - ---- - -## Creation trigger - -`OSEC-08` - `OrderSubmittedEvent` is created only after successful outbound -transmission/dispatch of a `new` intent. - -`OSEC-09` - In current runtime-oriented terms, the dispatch-success boundary is: - -1. intent was accepted for immediate send; -2. outbound `execution.apply_intents(...)` did not fail for the order key; -3. dispatch success boundary is reached for that outbound new-order send. - -`OSEC-10` - Failed venue/runtime submission creates no `OrderSubmittedEvent`. - -`OSEC-11` - Replace/cancel dispatches do not create a new -`OrderSubmittedEvent`. - ---- - -## Required field contract (v1, implemented boundary shape) - -`OSEC-12` - Required canonical boundary fields in this implemented slice: - -- `ts_ns_local_dispatch` -- `instrument` -- `client_order_id` -- `side` -- `order_type` -- `intended_price` -- `intended_qty` -- `time_in_force` - -Canonical ProcessingPosition authority is carried by `EventStreamEntry.position` -at canonical ingestion (`process_event_entry` / `process_canonical_event`), not -as an inline `OrderSubmittedEvent` model field in this slice. - -`OSEC-13` - Optional/correlation fields when available: - -- `intent_correlation_id` -- `dispatch_attempt_id` (if introduced in a future runtime boundary) -- venue/runtime correlation metadata - -`OSEC-14` - Optional/correlation fields are not canonical identity authority in -this contract. - ---- - -## Identity and correlation contract - -`OSEC-15` - Canonical order key for this v1 boundary is -`(instrument, client_order_id)`. - -`OSEC-16` - `client_order_id` is the stable dispatch/order correlation key in -this slice. - -`OSEC-17` - Venue/runtime IDs remain correlation metadata only for this slice. - -`OSEC-18` - Replace/cancel intents target an existing order key and do not -restart lifecycle from `Submitted`. - ---- - -## Projection and coexistence behavior (transitional) - -`OSEC-19` - `OrderSubmittedEvent` is the canonical authority for entering -`Submitted` in the current implemented boundary slice. - -`OSEC-20` - `CanonicalOrderProjection` is created/preserved at `submitted` from -the `OrderSubmittedEvent` reducer path in the current implemented slice. - -`OSEC-21` - `mark_intent_sent` remains compatibility/execution-control -bookkeeping during transition. - -`OSEC-22` - In current HFT runtime wiring, `OrderSubmittedEvent` processing is -performed before `mark_intent_sent` for successful `new` dispatches. Failed -`new` dispatches produce no `OrderSubmittedEvent`, and replace/cancel dispatches -produce no `OrderSubmittedEvent`. - -`OSEC-23` - Transitional coexistence requirement: `mark_intent_sent`-based -submitted sidecar seeding must be treated as idempotent/mirrored behavior under -future coexistence with `OrderSubmittedEvent`. - -`OSEC-24` - This contract introduces no post-submission transition authority. -Post-submission canonical authority remains deferred pending explicit canonical -execution-feedback source. - ---- - -## ProcessingPosition policy - -`OSEC-25` - Canonical acceptance order uses one global canonical position -counter across canonical event categories. - -`OSEC-26` - Category-local canonical counters are not allowed. - -`OSEC-27` - Position must not be derived from timestamps. - -`OSEC-28` - Ordering semantics must be coherent relative to canonical -`MarketEvent` and future canonical execution-feedback records. - ---- - -## Compatibility boundaries preserved - -`OSEC-29` - `OrderStateEvent` remains non-canonical. - -`OSEC-30` - `ingest_order_snapshots` behavior remains unchanged. - -`OSEC-31` - `DerivedFillEvent` remains compatibility projection behavior. - -`OSEC-32` - `FillEvent` ingress remains deferred. - -`OSEC-33` - Snapshot reducer behavior remains unchanged; no rewrite is introduced -by this contract. - -`OSEC-34` - This docs slice introduces no runtime behavior change. - ---- - -## No-double-authority rules - -`OSEC-35` - Submitted entry authority belongs to `OrderSubmittedEvent` in this -implemented slice. - -`OSEC-36` - Compatibility snapshots may mirror/advance sidecar projections only -under transitional compatibility rules; they are not canonical Submitted -authority. - -`OSEC-37` - Post-submission transitions remain deferred until explicit canonical -execution-feedback sources are defined and contracted. - -`OSEC-38` - Snapshot materialization must not become canonical Submitted -authority in this phase. - ---- - -## Explicitly out of scope - -`OSEC-39` - Changing `OrderSubmittedEvent` model shape beyond current implemented -contract fields. - -`OSEC-40` - Event taxonomy semantic reclassification beyond current implemented -`intent_related` status. - -`OSEC-41` - Runtime dispatch behavior expansion beyond current successful `new` -dispatch emission semantics. - -`OSEC-42` - `FillEvent` ingress implementation. - -`OSEC-43` - `OrderStateEvent` canonicalization. - -`OSEC-44` - Replay/storage/`ProcessingContext`/`EventStreamCursor` -implementation. - -`OSEC-45` - Broad order lifecycle migration or snapshot reducer migration. - ---- - -## Relationship to existing core contracts - -- [Core Stable Contract v1](core-stable-contract-v1.md) -- [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md) -- [Runtime-to-CoreConfiguration Contract Boundary v1](runtime-to-coreconfiguration-contract-v1.md) - diff --git a/docs/package-rename-stage-0-decision-v1.md b/docs/package-rename-stage-0-decision-v1.md deleted file mode 100644 index 94b5c8c..0000000 --- a/docs/package-rename-stage-0-decision-v1.md +++ /dev/null @@ -1,182 +0,0 @@ -# Package Rename Stage 0 Decision v1 - ---- - -## Purpose and scope - -This document records the Stage 0 naming decision for the package-rename track -across `core` and `core-runtime`. - -This is a planning record only. - -This page: - -- does not change production code; -- does not change tests; -- does not rename packages/directories in implementation yet; -- does not change imports, pyproject metadata, or JSON configs yet; -- does not change runtime behavior, adapters, reducers, or event taxonomy; -- does not implement deferred semantic items (`FillEvent` ingress, - `ExecutionFeedbackRecordSource`, `ProcessingContext`, replay/storage). - ---- - -## Inputs used - -- Current core distribution: `trading-framework` -- Current core import root: `trading_framework` -- Current core semantic subtree: `trading_framework.core` -- Current runtime distribution: `trading-runtime` -- Current runtime import root: `trading_runtime` -- Desired direction: align names with Core / Core Runtime terminology -- Critical design gate: top-level `core` import can create `core.core.*` unless - the current inner `core` package is also renamed/flattened. - ---- - -## Final naming targets (decision) - -### Core repository (`core`) - -- **Repository/folder display name:** keep `core` (already aligned) -- **Python import root target:** `tradingchassis_core` -- **Distribution/project name target:** `tradingchassis-core` -- **Internal package layout target:** keep current structure shape during rename - slice (`tradingchassis_core/core/...`) to avoid semantic/mechanical coupling -- **`core.core.*` decision:** avoid -- **Flatten `trading_framework.core.*` into `core.*` now?:** no (explicitly - deferred; would be a separate structural refactor) - -### Core Runtime repository (`core-runtime`) - -- **Repository/folder display name:** keep `core-runtime` (already aligned) -- **Python import root target:** `core_runtime` -- **Distribution/project name target:** `tradingchassis-core-runtime` -- **Internal package layout target:** keep current structure shape during rename - slice (`core_runtime/...`, same module topology as today) -- **`trading_runtime.*` to `core_runtime.*`:** yes - ---- - -## Candidate option comparison - -### A) Import root `core`, flattened layout, distribution `core` or `tradingchassis-core` - -- **Readability:** high if fully flattened -- **Collision risk:** medium/high (`core` is generic) -- **PyPI realism:** `core` is weak/high-conflict; `tradingchassis-core` is good -- **Import churn:** very high (import root + subtree flatten) -- **`class_path` churn:** medium (runtime still changes) -- **Docs alignment:** good -- **Maintainability:** potentially good long-term, but high migration risk now -- **Nested structure simplification:** yes - -### B) Import root `core`, accept `core.core.*` - -- **Readability:** low (`core.core.*` duplication) -- **Collision risk:** high (`core`) -- **PyPI realism:** weak if distribution is `core` -- **Import churn:** medium -- **`class_path` churn:** medium -- **Docs alignment:** partial -- **Maintainability:** poor naming ergonomics -- **Nested structure simplification:** no - -### C) Import root `tradingchassis_core`, distribution `tradingchassis-core` - -- **Readability:** good and explicit -- **Collision risk:** low -- **PyPI realism:** good -- **Import churn:** medium (mechanical, bounded) -- **`class_path` churn:** medium (runtime rename still required) -- **Docs alignment:** good (docs can still refer to Core conceptually) -- **Maintainability:** high (globally unique import root) -- **Nested structure simplification:** no immediate flatten; deferred - -### D) Hybrid: docs/repo names updated, imports remain `trading_framework` - -- **Readability:** mixed (conceptual and technical names diverge) -- **Collision risk:** low -- **PyPI realism:** unchanged -- **Import churn:** none now -- **`class_path` churn:** none now -- **Docs alignment:** partial -- **Maintainability:** medium/low (long transitional mismatch) -- **Nested structure simplification:** no - -### E) Compatibility aliases first before final rename - -- **Readability:** transitional complexity -- **Collision risk:** low if final names are unique -- **PyPI realism:** depends on chosen final names (good with option C targets) -- **Import churn:** staged, lower immediate blast radius -- **`class_path` churn:** staged with deprecation window -- **Docs alignment:** good if clearly documented -- **Maintainability:** good when time-boxed; poor if indefinite -- **Nested structure simplification:** not by itself - ---- - -## Recommended final target - -Adopt **Option C as final naming target** plus a **time-boxed Option E -compatibility phase** for migration safety. - -Rationale: - -1. Avoids the `core.core.*` naming trap without forcing an inner package - flatten/rename in the same slice. -2. Uses unique, realistic distribution names (`tradingchassis-*`) and avoids - generic package-name collision risk. -3. Preserves semantics and structure for a behavior-preserving mechanical rename. -4. Keeps room for a future separate structural simplification decision after the - rename has stabilized. - ---- - -## Explicit import and class_path mapping targets - -- `trading_framework.core.domain.types` -> - `tradingchassis_core.core.domain.types` -- `trading_framework.core.domain.processing` -> - `tradingchassis_core.core.domain.processing` -- `trading_runtime.backtest.engine.strategy_runner` -> - `core_runtime.backtest.engine.strategy_runner` -- `trading_runtime.strategies.debug_strategy:DebugStrategyV1` -> - `core_runtime.strategies.debug_strategy:DebugStrategyV1` - ---- - -## Compatibility strategy decision - -Use temporary compatibility shims as an explicit, time-boxed migration bridge: - -- Provide temporary re-export compatibility for: - - `trading_framework` -> `tradingchassis_core` - - `trading_runtime` -> `core_runtime` -- Maintain shims for one defined deprecation window (recommended: one minor - release cycle), with deprecation warnings. -- Require external JSON `strategy.class_path` and external imports to migrate - during that window. -- Remove shims after the window closes to prevent permanent dual-namespace debt. - ---- - -## Next implementation slice decision - -Choose **D: compatibility alias introduction first** as the smallest safe next -implementation slice after Stage 0. - -Then proceed with coordinated mechanical renames in both repos once compatibility -coverage is validated. - ---- - -## Non-goals for this decision - -- No implementation of package rename in this document. -- No reducer/event/runtime semantic changes. -- No adapter boundary changes. -- No replay/storage/event-stream persistence implementation. - ---- diff --git a/docs/post-submission-lifecycle-compatibility-map-v1.md b/docs/post-submission-lifecycle-compatibility-map-v1.md deleted file mode 100644 index 4f1b097..0000000 --- a/docs/post-submission-lifecycle-compatibility-map-v1.md +++ /dev/null @@ -1,144 +0,0 @@ -# Post-Submission Lifecycle Compatibility Map v1 - ---- - -## Purpose and scope - -This document freezes the current implementation-facing authority split for order -lifecycle semantics after submission in `core`. - -This is a docs-only contract slice: - -- it documents current lifecycle authority boundaries; -- it does not implement behavior; -- it does not change reducers or runtime behavior; -- it does not implement `FillEvent` ingress; -- it does not canonicalize `OrderStateEvent`. - -`PSLCM-01` - Main `docs` remains the semantic source of truth for Event, Event -Stream, Processing Order, Order lifecycle, and determinism semantics. - -`PSLCM-02` - This page is implementation-facing and freezes current authority -split behavior in `core` contracts; it does not redefine architecture semantics. - -`PSLCM-03` - This page must remain consistent with: - -- [Core Stable Contract v1](core-stable-contract-v1.md) -- [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md) -- [Runtime/Adapter Execution Feedback Source Contract v1](runtime-adapter-execution-feedback-source-contract-v1.md) -- [OrderSubmittedEvent / Dispatch Boundary Contract v1](order-submitted-event-contract-v1.md) - -Normative semantic references from main `docs`: - -- `docs/docs/00-guides/terminology.md` -- `docs/docs/20-concepts/event-model.md` -- `docs/docs/20-concepts/order-lifecycle.md` - ---- - -## Canonical authority today - -`PSLCM-04` - `OrderSubmittedEvent` is canonical authority for lifecycle entry at -`Submitted`. - -`PSLCM-05` - Current runtime wiring emits/processes `OrderSubmittedEvent` only -for successful `new` dispatches; failed `new` dispatches create no -`OrderSubmittedEvent`, and replace/cancel dispatches do not create new -`OrderSubmittedEvent` records. - -`PSLCM-06` - `ProcessingPosition` remains boundary/global acceptance-order -authority for canonical ingestion. Ordering authority is not timestamp-derived. - ---- - -## Compatibility authority today - -`PSLCM-07` - `OrderStateEvent` remains compatibility-only and non-canonical at -the canonical boundary. - -`PSLCM-08` - `ingest_order_snapshots` remains the compatibility snapshot -materialization path. - -`PSLCM-09` - `apply_order_state_event` remains the compatibility reducer and -projection path for post-submission lifecycle progression. - -`PSLCM-10` - `DerivedFillEvent` remains a non-canonical compatibility -projection artifact derived from snapshot progression. - -`PSLCM-11` - `mark_intent_sent` remains compatibility execution-control / -bookkeeping sidecar behavior and must not be interpreted as Event Stream -authority. - ---- - -## Current lifecycle compatibility map (frozen snapshot) - -`PSLCM-12` - Post-submission lifecycle progression remains -compatibility-governed until canonical execution-feedback source gates are -satisfied. - -| lifecycle transition | current source | canonical or compatibility classification | affected state/projection | semantic drift risk | future migration gate | -| --- | --- | --- | --- | --- | --- | -| none/new -> `Submitted` | successful `new` dispatch -> `OrderSubmittedEvent` canonical boundary processing (with `mark_intent_sent` bookkeeping sidecar) | canonical entry authority (`OrderSubmittedEvent`); sidecar bookkeeping remains compatibility | canonical order projection (`canonical_orders`) at `submitted`; bookkeeping (`inflight`, `last_sent_intents`) | medium (dual-path coexistence can be misread as dual authority) | retain single entry authority at `OrderSubmittedEvent`; keep `mark_intent_sent` non-authoritative | -| `Submitted` -> `Accepted` | snapshot ingestion/materialization (`ingest_order_snapshots` -> `OrderStateEvent` -> `apply_order_state_event`) | compatibility | compatibility order snapshots and sidecar lifecycle projection advancement | high (snapshot mapping and compatibility-state normalization) | canonical execution-feedback source and mapping required before authority move | -| `Submitted` -> `Rejected` | snapshot-derived `OrderStateEvent(state_type="rejected")` | compatibility | compatibility snapshots and sidecar projection | high | canonical execution-feedback source and deterministic correlation required | -| `Accepted` -> `PartiallyFilled` | snapshot-derived `OrderStateEvent(state_type="partially_filled")` | compatibility | compatibility snapshots; sidecar projection; snapshot-derived fill projection potential | high | canonical execution-feedback source with authoritative cumulative progression | -| `PartiallyFilled` -> `PartiallyFilled` | repeated snapshot cumulative progression updates | compatibility | compatibility snapshots; `DerivedFillEvent` projection emission on cumulative increase | high | explicit canonical fill granularity and no-double-counting policy | -| `Accepted`/`PartiallyFilled` -> `Filled` | snapshot-derived terminal state updates | compatibility | compatibility snapshots (terminal removal), sidecar projection terminal progression, snapshot-derived fill projection | high | canonical `FillEvent` ingress gates + explicit cutover policy | -| `Accepted`/`PartiallyFilled` -> `Canceled` | snapshot-derived terminal state updates | compatibility | compatibility snapshots (terminal removal), sidecar projection terminal progression | high | canonical execution-feedback source and deterministic ordering/correlation | - ---- - -## Guardrails (must hold in this phase) - -`PSLCM-13` - `OrderStateEvent` must remain rejected at canonical boundary -processing. - -`PSLCM-14` - `DerivedFillEvent` must remain non-canonical compatibility -projection behavior. - -`PSLCM-15` - Runtime `FillEvent` ingress remains gated by execution-feedback -source-authority requirements (`ExecutionFeedbackRecordSource` contract family). - -`PSLCM-16` - `mark_intent_sent` must not be treated as canonical Event Stream -authority. - -`PSLCM-17` - Snapshot progression must not be described or promoted as canonical -execution feedback in this phase. - ---- - -## Future migration gates - -`PSLCM-18` - Lifecycle authority migration for post-submission transitions may -begin only when all of the following are satisfied: - -- authoritative `ExecutionFeedbackRecordSource` exists for the target scope; -- deterministic strictly monotone non-timestamp `source_sequence` exists; -- source-authoritative liquidity and deterministic canonical correlation exist; -- explicit global `ProcessingPosition` merge policy exists; -- explicit no-double-counting cutover policy relative to `DerivedFillEvent` - exists. - -`PSLCM-19` - Post-submission lifecycle authority must move only after these -gates are satisfied and validated; until then, compatibility authority remains -frozen as documented here. - ---- - -## Explicit non-goals for this slice - -`PSLCM-20` - No snapshot-derived canonical `FillEvent` emission. - -`PSLCM-21` - No `OrderStateEvent` canonicalization. - -`PSLCM-22` - No `DerivedFillEvent` removal or behavior change. - -`PSLCM-23` - No lifecycle reducer rewrite. - -`PSLCM-24` - No adapter API work. - -`PSLCM-25` - No replay/storage/`ProcessingContext`/`EventStreamCursor` -implementation. - ---- diff --git a/docs/processing-context-event-stream-cursor-contract-v1.md b/docs/processing-context-event-stream-cursor-contract-v1.md deleted file mode 100644 index 487559c..0000000 --- a/docs/processing-context-event-stream-cursor-contract-v1.md +++ /dev/null @@ -1,198 +0,0 @@ -# ProcessingContext / EventStreamCursor Contract v1 - ---- - -## Purpose and scope - -This document defines docs-only ownership and boundary semantics for deferred -`ProcessingContext` abstraction work and runtime-owned `EventStreamCursor` -boundary responsibilities. - -This is a planning/contract slice only: - -- it does not implement `ProcessingContext`; -- it does not introduce new `EventStreamCursor` behavior; -- it does not change runtime behavior; -- it does not change reducers or event taxonomy; -- it does not implement canonical `FillEvent` ingress; -- it does not add adapter APIs; -- it does not canonicalize `OrderStateEvent`; -- it does not change `DerivedFillEvent` behavior; -- it does not change snapshot ingestion behavior; -- it does not implement replay/storage/EventStream persistence. - -`PCESC-01` - Main `docs` remains the semantic source of truth for Event, -Event Stream, Processing Order, Configuration, Runtime, and Venue Adapter -semantics. - -`PCESC-02` - This page is implementation-facing boundary planning for -future abstraction ownership only. It does not redefine architecture semantics. - -`PCESC-03` - This page must remain consistent with: - -- [Core Stable Contract v1](core-stable-contract-v1.md) -- [Venue Adapter Capability Model v1](venue-adapter-capability-model-v1.md) -- [Post-Submission Lifecycle Compatibility Map v1](post-submission-lifecycle-compatibility-map-v1.md) -- [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md) -- [Runtime/Adapter Execution Feedback Source Contract v1](runtime-adapter-execution-feedback-source-contract-v1.md) - -Normative semantic references from main `docs`: - -- `docs/docs/00-guides/terminology.md` -- `docs/docs/20-concepts/event-model.md` -- `docs/docs/20-concepts/time-model.md` -- `docs/docs/20-concepts/determinism-model.md` - ---- - -## Responsibility split - -### EventStreamCursor responsibility (conceptual) - -`PCESC-04` - `EventStreamCursor` is an ordering-only abstraction. - -`PCESC-05` - `EventStreamCursor` conceptually allocates/advances global -canonical `ProcessingPosition` values for Runtime canonical entry formation. - -`PCESC-06` - Cursor sequence semantics are deterministic and strictly monotone. - -`PCESC-07` - `EventStreamCursor` must not carry event payloads. - -`PCESC-08` - `EventStreamCursor` must not carry `CoreConfiguration`. - -`PCESC-09` - `EventStreamCursor` must not carry adapter handles. - -`PCESC-10` - `EventStreamCursor` must not carry persistence/storage handles. - -### ProcessingContext responsibility (conceptual) - -`PCESC-11` - `ProcessingContext` is runtime-owned invocation scope metadata. - -`PCESC-12` - `ProcessingContext` conceptually carries explicit -`CoreConfiguration` reference for canonical boundary invocation scope. - -`PCESC-13` - `ProcessingContext` conceptually carries declared capability scope -and merge-policy selection metadata. - -`PCESC-14` - `ProcessingContext` must not carry canonical event history. - -`PCESC-15` - `ProcessingContext` must not mutate `StrategyState` directly. - -`PCESC-16` - `ProcessingContext` must not redefine adapter capability semantics. - -`PCESC-17` - `ProcessingContext` must not become canonical core input payload -shape in this contract slice. - ---- - -## Ownership model - -`PCESC-18` - Runtime owns `ProcessingContext` and `EventStreamCursor` -abstractions (if introduced in future implementation slices). - -`PCESC-19` - Core owns canonical boundary validation and reduction of -`EventStreamEntry`. - -`PCESC-20` - Adapter owns venue/source capability exposure only. - -`PCESC-21` - `EventStreamEntry` remains minimal (`position`, `event`). - -`PCESC-22` - Configuration remains call-level processing input and must not -move into `EventStreamEntry` payload shape. - ---- - -## Current state snapshot (frozen for this phase) - -`PCESC-23` - Current runtime runner uses a runtime-owned `EventStreamCursor` -ordering helper for canonical positioned entry formation. - -`PCESC-24` - Current runtime runner creates `EventStreamEntry` records at the -runner boundary before calling `process_event_entry(...)`. - -`PCESC-25` - Current runtime runner passes `CoreConfiguration` explicitly into -`process_event_entry(...)` as call-level processing input. - -`PCESC-26` - Current compatibility `rc == 3` order/account snapshot branch -continues to bypass canonical `EventStreamEntry` by design and remains -compatibility behavior in this phase. - ---- - -## Conceptual future relation (non-implemented) - -`PCESC-27` - Future slices may extend/refine `EventStreamCursor` integration -while preserving current global ordering semantics. - -`PCESC-28` - If implemented in a future slice, `ProcessingContext` would gather -run/session invocation-scope metadata without changing canonical payload shapes. - -`PCESC-29` - Runtime would remain responsible for constructing -`EventStreamEntry` values from canonical events and positioned ordering metadata. - -`PCESC-30` - Core would remain non-owner of adapter polling and position -allocation orchestration. - -`PCESC-31` - This relation introduces no replay/storage/persistence semantics. - ---- - -## Out of scope - -`PCESC-32` - Replay engine implementation. - -`PCESC-33` - Event Stream storage/persistence implementation. - -`PCESC-34` - Adapter interface design or adapter API implementation. - -`PCESC-35` - Canonical runtime `FillEvent` ingress implementation. - -`PCESC-36` - Post-submission lifecycle migration away from compatibility -snapshot authority. - -`PCESC-37` - ControlTimeEvent queue/rate authority migration. - -`PCESC-38` - `OrderStateEvent` canonicalization. - -`PCESC-39` - `DerivedFillEvent` behavior change/removal. - ---- - -## Guardrails - -`PCESC-40` - `EventStreamCursor` must not derive `ProcessingPosition` from -timestamps. - -`PCESC-41` - `EventStreamCursor` must not reset or fork sequence authority -within one canonical stream scope. - -`PCESC-42` - `ProcessingContext` must not hide mutable configuration changes. - -`PCESC-43` - `ProcessingContext` must not smuggle venue-specific schemas into -core canonical processing payload shapes. - -`PCESC-44` - `ProcessingContext` must not define hidden state-mutation authority -outside Event processing. - -`PCESC-45` - `EventStreamEntry` must remain config-free and minimal. - -`PCESC-46` - `ProcessingPosition` remains global canonical ordering authority -and must remain non-timestamp-derived. - ---- - -## Future implementation prerequisites - -`PCESC-47` - A future implementation slice requires an explicit runtime refactor -plan before code changes. - -`PCESC-48` - Tests must preserve existing canonical event ordering behavior. - -`PCESC-49` - Tests must preserve existing compatibility snapshot behavior. - -`PCESC-50` - Tests must demonstrate that cursor-emitted sequence matches current -counter sequence for currently wired canonical paths. - -`PCESC-51` - First implementation path must not require core reducer changes. - ---- diff --git a/docs/reference/events-reference.md b/docs/reference/events-reference.md new file mode 100644 index 0000000..e1e79e4 --- /dev/null +++ b/docs/reference/events-reference.md @@ -0,0 +1,47 @@ +# Events and Intents Reference + +TradingChassis Core accepts canonical Event contracts and produces Intent/decision +contracts. Pydantic models are the schema source of truth. + +## Canonical Event Models + +- `MarketEvent`: book/trade market data input for state reduction +- `ControlTimeEvent`: canonical **control** wakeup; becomes stream history only + after Runtime injection. Reducer updates monotone time (and processing cursor + when positioned). Scheduling **obligations** are a separate non-canonical output; + see `../flows/control-time-and-scheduling.md`. +- `OrderSubmittedEvent`: canonical submitted-order acknowledgement +- `OrderExecutionFeedbackEvent`: canonical account/execution feedback +- `FillEvent`: canonical fill lifecycle update + +Canonical ingestion boundary: + +- `process_canonical_event(state, event, ...)` +- `process_event_entry(state, EventStreamEntry(...), ...)` + +## Processing Order Models + +- `ProcessingPosition` +- `EventStreamEntry` + +These models provide deterministic ordering metadata without implementing a full +stream storage/replay subsystem. + +## Intent Models + +- `OrderIntent` (discriminated union) +- `NewOrderIntent` +- `CancelOrderIntent` +- `ReplaceOrderIntent` +- `Price` +- `Quantity` + +## Non-canonical Output Models + +- `CandidateIntentRecord` with `CandidateIntentOrigin` +- `PolicyRiskDecision` +- `ExecutionControlDecision` +- `CoreStepDecision` +- `CoreStepResult` +- `ControlSchedulingObligation` (time-dependent **rate-limit** recheck hint; not + emitted for **inflight-only** deferral by default—see `../flows/control-time-and-scheduling.md`) diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md new file mode 100644 index 0000000..ff7a77f --- /dev/null +++ b/docs/reference/public-api.md @@ -0,0 +1,69 @@ +# Public API Reference + +The public package boundary is the `tradingchassis_core` root import. + +## Canonical Events + +- `MarketEvent` +- `ControlTimeEvent` +- `OrderSubmittedEvent` +- `OrderExecutionFeedbackEvent` +- `FillEvent` + +## Step APIs + +- `process_canonical_event` +- `process_event_entry` +- `run_core_step` +- `run_core_wakeup_reduction` +- `run_core_wakeup_decision` +- `run_core_wakeup_step` (ordered batch: reduce all entries, then evaluate Strategy once) + +## Step inputs/outputs + +- `EventStreamEntry` +- `ProcessingPosition` +- `CorePolicyAdmissionContext` +- `CoreExecutionControlApplyContext` +- `CoreStepDecision` +- `CoreStepResult` +- `CoreWakeupReductionResult` +- `CoreWakeupStrategyContext` +- `CoreWakeupStrategyEvaluator` + +## Supporting deterministic models + +- `CoreConfiguration` +- `StrategyState` +- `CandidateIntentRecord` +- `CandidateIntentOrigin` +- `PolicyRiskDecision` +- `ExecutionControlDecision` +- `ExecutionControl` +- `ControlSchedulingObligation` (non-canonical; **rate-limit** recheck hint in the + current slice—see `../flows/control-time-and-scheduling.md`) + +## Intents and numeric models + +- `OrderIntent` +- `NewOrderIntent` +- `CancelOrderIntent` +- `ReplaceOrderIntent` +- `Price` +- `Quantity` + +## Runtime-safe utilities + +- `NullEventBus` +- `RiskEngine` (Risk Engine; policy-only) +- `RiskConfig` + +## Publicly absent by design + +- `GateDecision` +- `compat_gate_decision` +- `ControlTimeQueueReevaluationContext` +- `CoreDecisionContext` +- `OrderStateEvent` +- `DerivedFillEvent` +- `VenueAdapter` / `VenuePolicy` diff --git a/docs/runtime-adapter-execution-feedback-source-contract-v1.md b/docs/runtime-adapter-execution-feedback-source-contract-v1.md deleted file mode 100644 index 9e022fd..0000000 --- a/docs/runtime-adapter-execution-feedback-source-contract-v1.md +++ /dev/null @@ -1,811 +0,0 @@ -# Runtime/Adapter Execution Feedback Source Contract v1 - ---- - -## Purpose and scope - -This document defines the source-authority boundary that a future runtime/adapter -execution-feedback source must satisfy before canonical `FillEvent` ingress can -be implemented. - -This is a docs-contract slice only: - -- it does not implement canonical `FillEvent` ingress; -- it does not add or implement adapter APIs; -- it does not modify runtime behavior; -- it does not canonicalize `OrderStateEvent`; -- it does not change `DerivedFillEvent` behavior; -- it does not change snapshot ingestion behavior; -- it does not change reducers or event taxonomy. - -`RAEFSC-01` - Current runtime remains ineligible for canonical `FillEvent` -ingress under the source-authority requirements defined in this contract. - -`RAEFSC-02` - Snapshot-derived fill progression remains compatibility projection -behavior (`DerivedFillEvent`) in this phase. - ---- - -## Semantic source of truth and precedence - -`RAEFSC-03` - Main `docs` repository remains the semantic source of truth for -Event semantics, Event Stream semantics, Processing Order, execution/order -lifecycle, and determinism. - -`RAEFSC-04` - This document is an implementation-facing boundary/source contract -for future runtime/adapter work. It does not redefine architecture semantics. - -`RAEFSC-05` - Runtime execution-feedback eligibility statements must remain -consistent with: - -- [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md) -- [Core Stable Contract v1](core-stable-contract-v1.md) - ---- - -## Current decision snapshot - -`RAEFSC-06` - No currently available runtime source satisfies canonical -`FillEvent` source-authority requirements (`REFC-10` through `REFC-16`). - -`RAEFSC-07` - Canonical runtime `FillEvent` ingress remains deferred. - -`RAEFSC-08` - `OrderStateEvent` remains compatibility-only and non-canonical at -the canonical boundary. - -`RAEFSC-09` - `DerivedFillEvent` remains compatibility projection and -non-canonical. - ---- - -## Source eligibility contract (v1) - -`RAEFSC-10` - A source is eligible for future canonical `FillEvent` ingress -only when records are explicit Venue or simulated-Venue execution-feedback -records from the execution path. - -`RAEFSC-11` - Source is explicitly ineligible when records are inferred from: - -- compatibility snapshot deltas; -- market trade feed inference; -- submit/modify/cancel synchronous return codes. - -`RAEFSC-12` - Offline/recorder artifacts are ineligible as runtime canonical -ingress unless replayed as authoritative Event Stream input under a positioned -ingestion contract that preserves deterministic `ProcessingPosition`. - ---- - -## Granularity contract (v1) - -`RAEFSC-13` - Acceptable v1 canonical execution-feedback granularity is -per-cumulative execution update. - -`RAEFSC-14` - Per-fill execution reports are acceptable only when each report -either: - -- carries authoritative cumulative filled quantity; or -- can be deterministically represented as cumulative updates without heuristic - reconstruction. - -`RAEFSC-15` - Cumulative filled quantity must be monotone per canonical order -key for accepted execution-feedback progression. - ---- - -## FillEvent field source-authority contract (v1) - -`RAEFSC-16` - Required `FillEvent` fields must be authoritative from execution -feedback source records (or direct deterministic mapping from those records and -canonical order lineage), not heuristic synthesis: - -- `ts_ns_exch` -- `ts_ns_local` -- `instrument` -- `client_order_id` -- `side` -- `filled_price` -- `cum_filled_qty` -- `time_in_force` -- `liquidity_flag` - -`RAEFSC-17` - Optional `FillEvent` fields, when present, must be source -authoritative: - -- `fee` -- `intended_price` -- `intended_qty` -- `remaining_qty` - -`RAEFSC-18` - Heuristic synthesis of required `FillEvent` fields is prohibited -in v1 unless a future explicit contract revision defines and permits that -behavior. - ---- - -## Liquidity flag policy (v1) - -`RAEFSC-19` - `liquidity_flag` classification (`maker`, `taker`, `unknown`) must -be source-authoritative execution-feedback data. - -`RAEFSC-20` - `unknown` is allowed only when the source explicitly reports -unknown or indeterminate liquidity classification. - -`RAEFSC-21` - Synthetic defaulting to `unknown` is prohibited in v1. - ---- - -## Identity and correlation contract (v1) - -`RAEFSC-22` - Canonical order key for this boundary is -`instrument + client_order_id`, unless a later explicit contract revision -changes canonical order identity semantics. - -`RAEFSC-23` - Source/runtime must provide deterministic correlation from -Venue-side order identifiers to canonical `client_order_id`. - -`RAEFSC-24` - Correlation to `OrderSubmittedEvent` lineage must be replay-stable -under equivalent input streams and configuration. - -`RAEFSC-25` - Replace/cancel successor identifiers require an explicit -deterministic mapping chain that preserves canonical order continuity and avoids -ambiguous identity resolution. - ---- - -## Ordering and ProcessingPosition contract (v1) - -`RAEFSC-26` - All future canonical `FillEvent` ingress must enter through -`EventStreamEntry` with global `ProcessingPosition` ordering at the canonical -boundary. - -`RAEFSC-27` - Processing acceptance order must not be derived from timestamps. -`Event Time` metadata does not define `ProcessingOrder`. - -`RAEFSC-28` - Source/adapter sequence contract must be deterministic and -replay-equivalent for equivalent inputs. - -`RAEFSC-29` - Runner merge ordering relative to canonical `MarketEvent`, -`OrderSubmittedEvent`, and `ControlTimeEvent` must be explicit and -replay-equivalent under the global positioned boundary. - ---- - -## No-double-counting contract (v1) - -`RAEFSC-30` - Before canonical `FillEvent` is enabled, one semantic authority -for fill progression must be defined for each source scope. - -`RAEFSC-31` - For overlapping scope, compatibility `DerivedFillEvent` path must -be either: - -- retired; or -- explicitly constrained to non-semantic observability with no canonical fill - progression side effects. - -`RAEFSC-32` - Duplicate semantic fill progression for the same canonical -order/cumulative state is prohibited. - -`RAEFSC-33` - Shadow/compare validation or explicit cutover reconciliation plan -is required before production dual-path operation. - ---- - -## Runtime/adapter API sketch (conceptual only) - -`RAEFSC-34` - A future conceptual source record (`ExecutionFeedbackRecord`) -should include, at minimum: - -- deterministic source sequence and/or source record id; -- authoritative execution-feedback payload for canonical `FillEvent` mapping; -- deterministic correlation fields needed to resolve canonical order identity. - -`RAEFSC-35` - Adapter guarantees (conceptual): - -- records are execution-feedback authoritative per eligibility clauses; -- sequence/id semantics are stable and deterministic; -- correlation fields are sufficient for replay-stable canonical mapping. - -`RAEFSC-36` - Runner assumptions (conceptual): - -- record-to-`FillEvent` mapping can be deterministic; -- positioned canonical merge can be performed via global `ProcessingPosition`; -- no-double-counting policy can be enforced at boundary cutover. - -`RAEFSC-37` - This section is conceptual only and does not define or introduce -implementation APIs in this phase. - ---- - -## Acceptance criteria for future implementation - -`RAEFSC-38` - Future implementation may begin only when all required -authoritative fields are available under this source contract. - -`RAEFSC-39` - Granularity semantics are stable and satisfy cumulative monotone -requirements per canonical order key. - -`RAEFSC-40` - Deterministic global ordering via positioned canonical boundary is -specified and testable. - -`RAEFSC-41` - Identity/correlation mapping is deterministic and replay-stable, -including replace/cancel successor handling. - -`RAEFSC-42` - Liquidity policy requirements are satisfied without synthetic -defaulting. - -`RAEFSC-43` - No-double-counting rules are explicit and testable. - -`RAEFSC-44` - Test plans can cover duplicates/regressions/idempotence and -ordering determinism before ingress rollout. - ---- - -## Explicitly out of scope for this contract slice - -`RAEFSC-45` - Implementing canonical runtime `FillEvent` ingress. - -`RAEFSC-46` - Adapter API implementation. - -`RAEFSC-47` - `OrderStateEvent` canonicalization. - -`RAEFSC-48` - `DerivedFillEvent` removal or behavior change. - -`RAEFSC-49` - Snapshot reducer rewrite or compatibility ingestion redesign. - -`RAEFSC-50` - Replay/storage/`ProcessingContext`/`EventStreamCursor` -implementation. - ---- - -## Appendix A: ExecutionFeedbackRecord adapter-facing source shape (Phase 4D) - -This appendix is adapter-facing and defines the minimum conceptual source shape -required before future canonical `FillEvent` ingress work may start. - -This appendix is docs-contract only: - -- it does not implement `FillEvent` ingress; -- it does not add adapter APIs; -- it does not make current runtime eligible; -- it does not modify runtime behavior; -- it does not change snapshot compatibility behavior. - -`RAEFSC-51` - Current feasibility decision remains **C**: no existing -runtime-adapter source satisfies this source contract end-to-end. - -`RAEFSC-52` - Canonical runtime `FillEvent` ingress remains deferred. - -`RAEFSC-53` - Compatibility projection authority is preserved in this phase: -`DerivedFillEvent` remains the active compatibility path and snapshot -materialization semantics remain unchanged. - ---- - -### A.1 Conceptual ExecutionFeedbackRecord source shape - -`RAEFSC-54` - The minimum conceptual adapter-facing source record -(`ExecutionFeedbackRecord`) for future canonical ingress requires: - -- `source_sequence` -- `ts_ns_exch` -- `ts_ns_local` -- `instrument` -- `client_order_id` -- optional `venue_order_id` -- `side` -- `time_in_force` -- `filled_price` -- `cum_filled_qty` -- `liquidity_flag` - -`RAEFSC-55` - Optional authoritative fields, when provided, include: - -- `fee` -- `remaining_qty` -- `intended_price` -- `intended_qty` -- source metadata such as `source_id`, `venue`, or adapter metadata when needed - for deterministic boundary mapping and observability. - -`RAEFSC-56` - This shape is conceptual boundary documentation only and does not -define or introduce implementation APIs in this phase. - ---- - -### A.2 source_sequence contract - -`RAEFSC-57` - `source_sequence` must be strictly monotone within the adapter's -execution-feedback source stream. - -`RAEFSC-58` - `source_sequence` must be deterministic for replay-equivalent -inputs and configuration. - -`RAEFSC-59` - `source_sequence` must not be timestamp-derived. - -`RAEFSC-60` - `source_sequence` must be stable enough for runner merge into -global `ProcessingPosition` ordering semantics. - ---- - -### A.3 Liquidity authority contract - -`RAEFSC-61` - `liquidity_flag` values (`maker`, `taker`, `unknown`) must be -source-authoritative. - -`RAEFSC-62` - `unknown` is allowed only when explicitly reported by the source -as unknown or indeterminate. - -`RAEFSC-63` - Synthetic defaulting to `unknown` is prohibited. - ---- - -### A.4 Identity and correlation contract - -`RAEFSC-64` - Canonical correlation to `instrument + client_order_id` is -required for source record eligibility. - -`RAEFSC-65` - `venue_order_id` is correlation metadata for v1 unless a future -explicit contract revision changes canonical identity semantics. - -`RAEFSC-66` - Replace/cancel successor correlation mapping must be explicit, -deterministic, and replay-stable. - -`RAEFSC-67` - Source records without deterministic canonical correlation are -ineligible for canonical ingress. - ---- - -### A.5 Ordering and merge contract - -`RAEFSC-68` - Adapter/source must provide deterministic source order for -execution-feedback records. - -`RAEFSC-69` - Runner owns merge into global `ProcessingPosition` ordering -across canonical `MarketEvent`, `OrderSubmittedEvent`, `ControlTimeEvent`, and -future canonical `FillEvent`. - -`RAEFSC-70` - `ProcessingOrder` must not be timestamp-derived. - -`RAEFSC-71` - Relative ordering policy for execution feedback versus other -canonical categories must be explicit before implementation. - ---- - -### A.6 No-double-counting cutover policy - -`RAEFSC-72` - Compatibility `DerivedFillEvent` progression remains current -authority until explicit cutover is defined and approved. - -`RAEFSC-73` - Future canonical `FillEvent` path must not duplicate semantic -fill progression for the same canonical order progression. - -`RAEFSC-74` - Pre-cutover operation requires either: - -- shadow-only comparison phase; or -- explicit authority cutover/reconciliation policy. - -`RAEFSC-75` - Duplicate semantic progression detection should include at least -`instrument`, `client_order_id`, and `cum_filled_qty`. - ---- - -### A.7 Ineligible current source classes (explicit) - -`RAEFSC-76` - The following source classes are ineligible in this phase: - -- order snapshots (compatibility materialization path); -- submit/modify/cancel return codes (not execution-feedback records); -- recorder/offline artifacts unless replayed through an authoritative positioned - stream contract; -- market trade feed inference; -- unwrapped `wait_order_response` without structured authoritative payload, - deterministic `source_sequence`, and required field authority. - ---- - -### A.8 Acceptance criteria before implementation planning - -`RAEFSC-77` - Implementation planning for canonical ingress requires all of: - -- source record channel exists; -- required fields are authoritative; -- liquidity semantics satisfy A.3; -- deterministic `source_sequence` exists; -- canonical correlation exists per A.4; -- merge ordering policy exists per A.5; -- no-double-counting policy exists per A.6; -- tests are possible for duplicates/regressions/idempotence/ordering. - -`RAEFSC-78` - Until `RAEFSC-77` is satisfied, feasibility remains decision **C** -and canonical runtime `FillEvent` ingress stays deferred. - ---- - -## Appendix B: Adapter API capability contract (Phase 4F) - -This appendix defines a docs-only adapter API capability contract for future -execution-feedback sources. - -This appendix is contract-only: - -- it does not add or implement production adapter APIs; -- it does not modify runtime behavior; -- it does not implement canonical `FillEvent` ingress; -- it does not canonicalize `OrderStateEvent`; -- it does not change `DerivedFillEvent` or snapshot compatibility behavior; -- it does not change reducers or event taxonomy; -- it does not implement replay/storage/`ProcessingContext`/`EventStreamCursor`. - -`RAEFSC-79` - This appendix defines the future adapter-facing capability -contract for authoritative `ExecutionFeedbackRecord` sourcing only. - -`RAEFSC-80` - Current runtime remains ineligible for canonical `FillEvent` -ingress under this source-authority contract. - -`RAEFSC-81` - Snapshot-derived compatibility projection remains the active -semantic authority in this phase (`DerivedFillEvent` and snapshot path -unchanged). - -`RAEFSC-82` - Canonical runtime `FillEvent` ingress remains deferred. - ---- - -### B.1 Ownership and boundary contract - -`RAEFSC-83` - The execution-feedback source capability belongs to the -venue-side adapter boundary. - -`RAEFSC-84` - Existing execution command submission boundary remains outbound -only; it is not redefined by this appendix. - -`RAEFSC-85` - Runner remains responsible for orchestration and global -`ProcessingPosition` merge policy at the canonical boundary. - -`RAEFSC-86` - Adapter/source capability must not mutate `StrategyState` -directly. - -`RAEFSC-87` - Adapter/source capability must not emit canonical events directly -and must not call canonical processing entry points. - ---- - -### B.2 Conceptual capability interface (docs only) - -`RAEFSC-88` - Future conceptual interface name is -`ExecutionFeedbackRecordSource`. - -`RAEFSC-89` - Conceptual method: -`drain_execution_feedback_records() -> Sequence[ExecutionFeedbackRecord]`. - -`RAEFSC-90` - `drain_execution_feedback_records` is non-blocking. - -`RAEFSC-91` - When no records are available, the method returns an empty -sequence. - -`RAEFSC-92` - Already-drained records must not be returned again. - -`RAEFSC-93` - Records returned by one drain call must be in deterministic -source acceptance order. - -`RAEFSC-94` - This interface remains conceptual documentation only in Phase 4F -and introduces no code API additions. - ---- - -### B.3 source_sequence requirements - -`RAEFSC-95` - `source_sequence` must be strictly monotone within the source -stream. - -`RAEFSC-96` - `source_sequence` must be deterministic for replay-equivalent -inputs and configuration. - -`RAEFSC-97` - `source_sequence` must not be derived from timestamps. - -`RAEFSC-98` - Duplicate or regressing `source_sequence` values are hard -contract failures. - -`RAEFSC-99` - `source_sequence` semantics must be suitable for deterministic -runner merge policy into global `ProcessingPosition`. - ---- - -### B.4 Error semantics contract - -`RAEFSC-100` - Missing required authoritative fields for -`ExecutionFeedbackRecord` are hard contract failures. - -`RAEFSC-101` - Non-monotone `source_sequence` is a hard contract failure. - -`RAEFSC-102` - Invalid liquidity semantics relative to A.3 are hard contract -failures. - -`RAEFSC-103` - Unresolved canonical correlation relative to A.4 is a hard -contract failure. - -`RAEFSC-104` - Malformed authoritative records must not be silently dropped. - ---- - -### B.5 Runtime loop integration contract (future implementation boundary) - -`RAEFSC-105` - Future runner integration may perform at most one non-blocking -feedback drain per wakeup after timestamp adoption and before rc-specific -branch processing. - -`RAEFSC-106` - Feedback draining is orthogonal to rc-specific market (`rc == 2`) -and snapshot/order-response (`rc == 3`) branches. - -`RAEFSC-107` - Current market and snapshot branch behavior remains unchanged -until a later explicit implementation phase. - -`RAEFSC-108` - Drained execution-feedback records are source records only and do -not directly mutate state until mapped to canonical boundary events by the -runner. - -`RAEFSC-109` - Global `ProcessingPosition` assignment for canonical merge -remains runner responsibility. - ---- - -### B.6 FillEvent mapping boundary contract - -`RAEFSC-110` - Adapter/source provides `ExecutionFeedbackRecord` only. - -`RAEFSC-111` - Runner maps eligible records to canonical `FillEvent` at a later -implementation phase. - -`RAEFSC-112` - Adapter/source must not construct canonical `FillEvent`. - -`RAEFSC-113` - Adapter/source must not invoke canonical `process_event_entry`. - -`RAEFSC-114` - `liquidity_flag` must come from source records only under A.3. - -`RAEFSC-115` - Synthetic population of required canonical mapping fields is -prohibited. - ---- - -### B.7 No-double-counting and cutover contract - -`RAEFSC-116` - First implementation path should be shadow-only unless a -separate explicit cutover decision is approved. - -`RAEFSC-117` - During shadow-only operation, `DerivedFillEvent` and snapshot -compatibility path remain semantic authority. - -`RAEFSC-118` - Authority cutover by source scope requires explicit subsequent -decision and test-backed reconciliation rules. - -`RAEFSC-119` - Duplicate semantic progression key must include at least -`instrument`, `client_order_id`, and `cum_filled_qty`. - ---- - -### B.8 Test obligations before implementation - -`RAEFSC-120` - Adapter contract tests are required. - -`RAEFSC-121` - Deterministic `source_sequence` tests are required. - -`RAEFSC-122` - Drain idempotence tests are required. - -`RAEFSC-123` - Mapping contract tests are required. - -`RAEFSC-124` - No-double-counting shadow tests are required. - -`RAEFSC-125` - Runtime global merge ordering tests are required. - -`RAEFSC-126` - Snapshot and `DerivedFillEvent` regression guards are required. - ---- - -### B.9 Explicitly out of scope for Phase 4F - -`RAEFSC-127` - Code interface addition. - -`RAEFSC-128` - hftbacktest or other adapter implementation work. - -`RAEFSC-129` - Runtime canonical `FillEvent` ingress implementation. - -`RAEFSC-130` - Reducer changes. - -`RAEFSC-131` - `OrderStateEvent` canonicalization. - -`RAEFSC-132` - `DerivedFillEvent` removal or behavior change. - -`RAEFSC-133` - Replay/storage/`EventStreamCursor`/`ProcessingContext` -implementation. - ---- - -## Appendix C: hftbacktest source feasibility and gap decision (Phase 4H) - -This appendix records the hftbacktest-specific feasibility decision from Phase -4G and documents the exact source/adapter gap required before any canonical -`FillEvent` ingress planning. - -This appendix is docs-contract only: - -- it does not implement canonical `FillEvent` ingress; -- it does not add or implement adapter APIs; -- it does not modify runtime behavior; -- it does not canonicalize `OrderStateEvent`; -- it does not change `DerivedFillEvent` behavior; -- it does not change snapshot ingestion behavior; -- it does not change reducers or event taxonomy; -- it does not implement replay/storage/`ProcessingContext`/`EventStreamCursor`. - -`RAEFSC-134` - Appendix C scope is hftbacktest-specific feasibility and gap -documentation only; no implementation behavior changes are introduced. - ---- - -### C.1 Decision snapshot - -`RAEFSC-135` - Current hftbacktest/core-runtime integration feasibility remains -decision **C** for `ExecutionFeedbackRecordSource` eligibility. - -`RAEFSC-136` - No currently exposed hftbacktest/core-runtime source satisfies -the `ExecutionFeedbackRecordSource` contract end-to-end under Appendices A and -B. - -`RAEFSC-137` - Canonical runtime `FillEvent` ingress remains deferred for the -hftbacktest integration in this phase. - ---- - -### C.2 Current exposed source classes and classification - -`RAEFSC-138` - `rc == 3` order snapshot path (`orders()` materialization and -compatibility ingestion) is classified as **partial/ineligible** for canonical -source authority: - -- some required fields may be present in snapshots; -- source class remains compatibility snapshot materialization, not explicit - execution-feedback records; -- required deterministic non-timestamp `source_sequence` is not exposed; -- source-authoritative `liquidity_flag` is not satisfied in current runtime - exposure; -- therefore it is ineligible for canonical ingress under A.7/B requirements. - -`RAEFSC-139` - submit/modify/cancel synchronous return codes are classified as -**ineligible**: - -- they represent outbound command status only; -- they are not execution-feedback records and cannot satisfy required - authoritative field payload requirements. - -`RAEFSC-140` - `wait_next(... include_order_resp=True)` response signaling is -classified as **insufficient/ineligible**: - -- it provides wakeup signaling that an order response occurred; -- it does not provide structured authoritative execution-feedback payload. - -`RAEFSC-141` - `wait_order_response` hook (as currently unwrapped/unused in -runtime boundary) is classified as **insufficient/ineligible**: - -- current exposure does not provide a structured authoritative source record - satisfying Appendix A required shape; -- deterministic source sequencing and full field authority are not provided by - current integration boundary. - -`RAEFSC-142` - `last_trades` market-trade feed is classified as **ineligible** -for canonical order execution feedback: - -- it is market-trade data, not deterministic own-order execution-feedback - records with canonical order correlation guarantees. - -`RAEFSC-143` - Latent hftbacktest order-structure fields (including potential -maker/taker-style flags) are classified as **insufficient unless surfaced -through explicit authoritative execution-feedback records**: - -- latent/internal field presence alone does not satisfy source-channel - eligibility; -- required source contract semantics must be satisfied at the adapter-facing - record boundary. - ---- - -### C.3 Exact missing requirements - -`RAEFSC-144` - Current hftbacktest/core-runtime integration lacks an explicit -adapter-facing execution-feedback record channel matching Appendix A required -shape and Appendix B drain semantics. - -`RAEFSC-145` - Current integration lacks deterministic, strictly monotone, -non-timestamp-derived `source_sequence` semantics suitable for runner merge. - -`RAEFSC-146` - Current integration lacks source-authoritative `liquidity_flag` -semantics satisfying A.3 without synthetic defaulting. - -`RAEFSC-147` - Current integration does not provide contracted authoritative -per-cumulative execution update granularity for canonical source records. - -`RAEFSC-148` - Current integration does not expose deterministic replay-stable -canonical correlation guarantees to `instrument + client_order_id` through an -explicit source record channel. - -`RAEFSC-149` - Deterministic replace/cancel successor correlation mapping chain -requirements are not currently satisfied at an authoritative source-record -boundary. - -`RAEFSC-150` - Global `ProcessingPosition` merge policy for future execution -feedback remains runner-owned and must be explicitly specified before -implementation. - -`RAEFSC-151` - No-double-counting cutover policy relative to -`DerivedFillEvent` compatibility progression remains required before canonical -dual-path operation. - ---- - -### C.4 Minimum required extension boundary (future, non-implemented) - -`RAEFSC-152` - Minimum required extension is a hftbacktest wrapper/adapter -capability that provides authoritative `ExecutionFeedbackRecordSource` -semantics at the venue-side adapter boundary. - -`RAEFSC-153` - Future source records must satisfy Appendix A required source -shape, field authority, correlation, granularity, and liquidity clauses. - -`RAEFSC-154` - Adapter/source capability must satisfy Appendix B drain -capability semantics (`drain_execution_feedback_records`) including deterministic -ordering and non-replay of drained records. - -`RAEFSC-155` - Adapter/source capability must own and enforce deterministic -strictly monotone non-timestamp `source_sequence` semantics. - -`RAEFSC-156` - Runner remains owner of global `ProcessingPosition` merge policy -and canonical positioned ingestion ordering across categories. - -`RAEFSC-157` - Until explicit authority cutover, current snapshot compatibility -path (`OrderStateEvent` materialization and `DerivedFillEvent`) remains -unchanged semantic authority, and first future implementation path should be -shadow-only unless separately approved. - ---- - -### C.5 Explicit non-goals for Phase 4H - -`RAEFSC-158` - Do not promote order snapshots to canonical execution-feedback -authority in this phase. - -`RAEFSC-159` - Do not treat submit/modify/cancel return codes as canonical -execution feedback. - -`RAEFSC-160` - Do not infer canonical fill authority from market-trade feed. - -`RAEFSC-161` - Do not synthesize `liquidity_flag` to satisfy required field -authority. - -`RAEFSC-162` - Do not canonicalize `OrderStateEvent` in this phase. - -`RAEFSC-163` - Do not remove or alter `DerivedFillEvent` behavior in this -phase. - -`RAEFSC-164` - Do not implement adapter APIs in this phase. - ---- - -### C.6 Future implementation gate for hftbacktest scope - -`RAEFSC-165` - Canonical `FillEvent` implementation planning for hftbacktest -scope may begin only after all C.3 missing requirements are satisfied under -Appendix A/B contracts. - -`RAEFSC-166` - First implementation path should remain shadow-only unless a -separate explicit authority cutover decision is approved. - -`RAEFSC-167` - Before implementation/cutover, tests must be possible and -planned for: - -- deterministic `source_sequence` monotonicity and non-timestamp derivation; -- required-field source authority (including `liquidity_flag`); -- deterministic canonical correlation (including successor mapping where - applicable); -- no-double-counting behavior relative to `DerivedFillEvent`; -- deterministic global merge ordering under runner-owned `ProcessingPosition`. - ---- diff --git a/docs/runtime-execution-feedback-contract-v1.md b/docs/runtime-execution-feedback-contract-v1.md deleted file mode 100644 index 395c4c0..0000000 --- a/docs/runtime-execution-feedback-contract-v1.md +++ /dev/null @@ -1,166 +0,0 @@ -# Runtime Execution Feedback Contract v1 - ---- - -## Purpose and scope - -This document defines a boundary contract for when runtime is allowed to emit -canonical execution feedback into `core`, specifically `FillEvent`. - -This is a docs-contract slice only: - -- it does not implement `FillEvent` ingress; -- it does not change runtime behavior; -- it does not canonicalize `OrderStateEvent`; -- it does not change compatibility projection behavior (`DerivedFillEvent`); -- it does not introduce new canonical event types. - ---- - -## Normative sources and precedence - -`REFC-01` - Main `docs` repository remains the semantic source of truth for -Event semantics, Event Stream, Processing Order, and execution/order lifecycle: - -- `docs/docs/00-guides/terminology.md` -- `docs/docs/20-concepts/event-model.md` -- `docs/docs/20-concepts/order-lifecycle.md` -- `docs/docs/20-concepts/time-model.md` -- `docs/docs/20-concepts/state-model.md` - -`REFC-02` - `core` implementation snapshot semantics are governed by -[Core Stable Contract v1](core-stable-contract-v1.md). - -`REFC-03` - This page is an implementation-facing core/runtime boundary contract -for current and near-future runtime execution feedback eligibility. It does not -redefine architecture semantics. - ---- - -## Current classification snapshot - -`REFC-04` - `FillEvent` is a canonical execution-event candidate in `core`. - -`REFC-05` - `DerivedFillEvent` is a compatibility projection artifact and is -non-canonical. - -`REFC-06` - `OrderStateEvent` is compatibility-only and non-canonical at the -canonical boundary. - -`REFC-07` - Snapshot-derived cumulative fill progression in current runtime flow -is not canonical-grade execution feedback for canonical `FillEvent` emission. - ---- - -## Runtime execution feedback contract v1 - -`REFC-08` - Runtime may emit canonical `FillEvent` only when source records are -explicit authoritative execution-feedback records from Venue or simulated Venue -execution path. - -`REFC-09` - Runtime must not emit canonical `FillEvent` from inference based -solely on compatibility order snapshots (`OrderStateEvent` materialization and -derived cumulative progression deltas). - ---- - -## FillEvent field source-authority matrix (v1) - -Current runtime-source statements below describe the current snapshot-driven path -as observed in this slice (`orders` snapshots -> `OrderStateEvent` -> -`DerivedFillEvent`), not a canonical execution feedback path. - -| FillEvent field | Required authority | Current runtime source availability | Sufficient now? | Reason if insufficient | -| --- | --- | --- | --- | --- | -| `ts_ns_exch` | Execution-feedback record timestamp for the execution update | Present from order snapshot timestamp | No | Snapshot timestamp is not guaranteed to represent an explicit canonical execution-feedback record boundary | -| `ts_ns_local` | Runtime receipt timestamp for the execution-feedback record | Present from order snapshot timestamp | No | Same boundary issue as `ts_ns_exch`; snapshot materialization is compatibility path | -| `instrument` | Execution-feedback record instrument identity | Present in snapshot/materialization context | No (as full contract) | Field exists, but source channel is compatibility snapshot path, not explicit execution feedback channel | -| `client_order_id` | Stable execution-feedback order identity | Present from snapshot order id | No (as full contract) | Identity exists, but source granularity/channel remains snapshot compatibility path | -| `side` | Authoritative side in execution feedback | Present in snapshot order view | No (as full contract) | Side exists, but source granularity/channel remains snapshot compatibility path | -| `filled_price` | Authoritative fill/execution-report price for emitted event granularity | Best-effort snapshot exec price may be present | No | Snapshot-provided price semantics are not contracted here as canonical execution-feedback granularity | -| `cum_filled_qty` | Authoritative cumulative filled quantity bound to execution-feedback record | Present as snapshot cumulative execution quantity | No | Available only via snapshot progression; not explicit execution feedback record channel | -| `time_in_force` | Authoritative order execution context | Present in snapshot order view | No (as full contract) | Field exists, but channel is compatibility snapshot materialization | -| `liquidity_flag` | Authoritative maker/taker/unknown classification in execution feedback contract | Not available in current snapshot-derived path | No | Required field lacks authoritative source in current runtime path | -| `intended_price` | Authoritative intended order price context when provided | Present in snapshot order view | No (as full contract) | Optional field may be present, but canonical source-channel requirements are unmet | -| `intended_qty` | Authoritative intended order quantity context when provided | Present in snapshot order view | No (as full contract) | Optional field may be present, but canonical source-channel requirements are unmet | -| `remaining_qty` | Authoritative remaining quantity context when provided | Present in snapshot order view | No (as full contract) | Optional field may be present, but canonical source-channel requirements are unmet | -| `fee` | Authoritative execution fee/rebate from execution feedback | Not available in current snapshot-derived path | No | Optional field unavailable in current path; no authoritative execution feedback source | - ---- - -## Minimum eligibility criteria for canonical FillEvent emission - -`REFC-10` - Source must be explicit execution feedback (Venue or simulated -Venue execution path), not inferred solely from compatibility snapshots. - -`REFC-11` - Runtime must define stable emitted-event granularity (for example, -per execution report or per cumulative execution update) and preserve it -deterministically across replay-equivalent runs. - -`REFC-12` - All required `FillEvent` fields must come from authoritative source -records under the runtime execution feedback contract. - -`REFC-13` - Required fields must not be heuristic/synthetic unless a future -explicit contract revision defines and permits such synthesis semantics. - -`REFC-14` - Canonical acceptance ordering must be deterministic via -`ProcessingPosition`, not timestamp-derived ordering. - -`REFC-15` - Runtime-emitted canonical `FillEvent` behavior must align with -existing `apply_fill_event` idempotence semantics (duplicate/regressing -cumulative progression as no-op). - -`REFC-16` - Runtime must define no-double-counting behavior between canonical -execution feedback path and compatibility projection path before any dual-path -operation. - ---- - -## Compatibility boundary preserved - -`REFC-17` - Snapshot-derived cumulative progression remains compatibility -projection (`DerivedFillEvent`) in current flow. - -`REFC-18` - `OrderStateEvent` remains compatibility-only and non-canonical at -the canonical boundary. - -`REFC-19` - This contract does not modify snapshot ingestion behavior. - ---- - -## Current deferred status - -`REFC-20` - Current runtime does not satisfy this v1 execution-feedback -contract for canonical `FillEvent` emission. - -`REFC-21` - Explicit runtime `FillEvent` ingress remains deferred. - -`REFC-22` - Snapshot-derived cumulative progression remains compatibility -projection behavior in this phase. - ---- - -## Prohibited behavior in this phase - -`REFC-23` - Do not promote `OrderStateEvent` to canonical execution event in -this phase. - -`REFC-24` - Do not derive canonical `FillEvent` from snapshot deltas alone. - -`REFC-25` - Do not synthesize required `liquidity_flag` as `"unknown"` unless a -future explicit contract revision permits and defines that behavior. - -`REFC-26` - Do not dual-write canonical `FillEvent` and `DerivedFillEvent` for -the same source path without explicit reconciliation/no-double-counting rules. - ---- - -## Future implementation gate - -`REFC-27` - Runtime `FillEvent` ingress implementation may start only after a -runtime adapter/source provides authoritative execution-feedback records that -satisfy `REFC-10` through `REFC-16`. - -`REFC-28` - Until then, execution feedback canonicalization remains deferred and -compatibility projection behavior remains unchanged. - diff --git a/docs/runtime-to-coreconfiguration-contract-v1.md b/docs/runtime-to-coreconfiguration-contract-v1.md deleted file mode 100644 index 6a5dc22..0000000 --- a/docs/runtime-to-coreconfiguration-contract-v1.md +++ /dev/null @@ -1,207 +0,0 @@ -# Runtime-to-CoreConfiguration Contract Boundary v1 - ---- - -## Purpose and scope - -This document defines a **boundary contract draft (v1)** for how runtime-owned run -configuration is mapped into `CoreConfiguration` before calling core canonical -processing APIs. - -This is a boundary-contract slice that originated as planning-oriented guidance -and now documents the currently implemented ownership boundary: - -- it defines ownership boundaries and validation expectations; -- it documents the minimum mapping target required by current core behavior; -- runtime mapping is implemented in `core-runtime` under runtime ownership; -- this page does not redefine runtime implementation internals; -- it does not introduce new core behavior. - ---- - -## Normative sources and precedence - -`RCC-01` — Semantic definitions remain in the main `docs` repository and are the -source of truth, including: - -- `docs/docs/00-guides/terminology.md` -- `docs/docs/20-concepts/event-model.md` -- `docs/docs/20-concepts/state-model.md` -- `docs/docs/20-concepts/time-model.md` - -`RCC-02` — Current core implementation guarantees are defined by: - -- [Core Stable Contract v1](core-stable-contract-v1.md) -- [CoreConfiguration to Positioned Market Contract](coreconfiguration-positioned-market-contract.md) - -`RCC-03` — If broader architecture targets in main docs exceed current core -implementation, this contract only defines runtime-to-core boundary obligations -needed to satisfy current core v1 behavior. - ---- - -## Boundary ownership model - -`RCC-04` — `core` consumes semantic configuration only through -`CoreConfiguration`. - -`RCC-05` — Runtime owns reading external run configuration inputs (for example: -run JSON, live config, backtest config). - -`RCC-06` — Runtime owns mapping run configuration into `CoreConfiguration` -before invoking core canonical processing/fold APIs. - -`RCC-07` — `core` must not read runtime JSON files directly. - -`RCC-08` — `core` must not depend on runtime/engine config classes (including -`HftEngineConfig`, live engine config types, or runtime config classes) at the -configuration boundary. - -`RCC-09` — A run config may contain multiple sections (for example `engine`, -`strategy`, `risk`, `core`), but `core` receives only `CoreConfiguration`. - -`RCC-10` — No duplicate maintenance principle: - -- core-semantic values must have one semantic source of truth in run config; -- if runtime also needs those values, runtime reuses/maps from that same source, - rather than maintaining divergent duplicates. - ---- - -## Minimum v1 mapping target for current core behavior - -`RCC-11` — Runtime-produced `CoreConfiguration` must provide: - -- `CoreConfiguration.version` -- `CoreConfiguration.payload.market.instruments..tick_size` -- `CoreConfiguration.payload.market.instruments..lot_size` -- `CoreConfiguration.payload.market.instruments..contract_size` - -`RCC-12` — This v1 target is intentionally minimal and reflects current core -contract needs. It does not define a complete future runtime schema. - ---- - -## Validation and failure expectations - -`RCC-13` — Missing core-semantic configuration section at runtime boundary must -fail before canonical event processing (**explicit-or-fail**). - -`RCC-14` — Missing `market` / `instruments` / `` mapping path must -fail. - -`RCC-15` — Missing required instrument fields (`tick_size`, `lot_size`, -`contract_size`) must fail. - -`RCC-16` — Invalid values must fail, including: - -- `None` -- `bool` -- non-numeric -- non-finite -- non-positive - -`RCC-17` — Boundary failures must occur before events are folded into core -state transitions. - -`RCC-18` — Runtime validates before calling core; core boundary validation still -remains authoritative at call time. - ---- - -## Illustrative run-config shape (non-normative) - -This shape is an example for boundary explanation only; it is not a required -schema. - -```json -{ - "engine": { - "...": "..." - }, - "strategy": { - "...": "..." - }, - "risk": { - "...": "..." - }, - "core": { - "version": "v1", - "market": { - "instruments": { - "": { - "tick_size": 0.01, - "lot_size": 0.001, - "contract_size": 1.0 - } - } - } - } -} -``` - ---- - -## Corresponding CoreConfiguration shape produced by runtime - -```json -{ - "version": "v1", - "payload": { - "market": { - "instruments": { - "": { - "tick_size": 0.01, - "lot_size": 0.001, - "contract_size": 1.0 - } - } - } - } -} -``` - ---- - -## Boundary responsibility table - -| Boundary concern | Owner | Contract expectation | -| --- | --- | --- | -| Run JSON / live config / backtest config reading | Runtime | Runtime reads/parses external configuration inputs. | -| `CoreConfiguration` object construction | Runtime (constructs), core (consumes) | Runtime constructs `CoreConfiguration`; core accepts only `CoreConfiguration` at boundary APIs. | -| Reducer semantics | Core | Core owns deterministic reducer behavior and canonical boundary semantics. | -| Validation | Runtime + core | Runtime validates before call; core boundary still validates and rejects invalid/missing required semantics. | - ---- - -## Explicitly out of scope for this v1 draft - -`RCC-19` — Runtime implementation details. - -`RCC-20` — JSON schema implementation. - -`RCC-21` — Live/backtest adapter-specific mapping internals. - -`RCC-22` — Runtime storage/persistence semantics. - -`RCC-23` — Event Stream storage or replay engine implementation. - -`RCC-24` — Control-Time Event injection implementation details. - -`RCC-25` — New canonical event type introduction. - -`RCC-26` — `OrderStateEvent` canonicalization. - -`RCC-27` — Any change to `FillEvent`, `CoreConfiguration`, `EventStreamEntry`, -or core processing API behavior. - -`RCC-28` — `ProcessingContext` / `EventStreamCursor` introduction. - ---- - -## Future work notes (non-binding) - -- Future runtime phases may define concrete mapping mechanics and schemas under - runtime ownership. -- Any future expansion of canonical event taxonomy must be handled as a separate - explicit semantic change, not as part of this boundary draft. diff --git a/docs/semantic-core-upgrade-milestone-closure-v1.md b/docs/semantic-core-upgrade-milestone-closure-v1.md deleted file mode 100644 index 7ba3c1c..0000000 --- a/docs/semantic-core-upgrade-milestone-closure-v1.md +++ /dev/null @@ -1,109 +0,0 @@ -# Semantic Core Upgrade Milestone Closure v1 - ---- - -## Purpose and scope - -This document records a docs-only milestone closure snapshot for the current -Semantic Core Upgrade state across `core` and `core-runtime`. - -This page: - -- does not change production code; -- does not change runtime behavior; -- does not change reducers or event taxonomy; -- does not implement `FillEvent` runtime ingress; -- does not add adapter APIs; -- does not canonicalize `OrderStateEvent`; -- does not change `DerivedFillEvent` or snapshot ingestion behavior; -- does not implement `ProcessingContext`; -- does not implement replay/storage/EventStream persistence. - ---- - -## Semantic source and contract references - -Main semantic source of truth remains the main `docs` repository, including: - -- `docs/docs/00-guides/terminology.md` -- `docs/docs/20-concepts/event-model.md` -- `docs/docs/20-concepts/order-lifecycle.md` -- `docs/docs/20-concepts/determinism-model.md` -- `docs/docs/20-concepts/state-model.md` - -Implementation-facing contract references in `core/docs`: - -- [Core Stable Contract v1](core-stable-contract-v1.md) -- [Runtime-to-CoreConfiguration Contract Boundary v1](runtime-to-coreconfiguration-contract-v1.md) -- [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md) -- [Runtime/Adapter Execution Feedback Source Contract v1](runtime-adapter-execution-feedback-source-contract-v1.md) -- [Post-Submission Lifecycle Compatibility Map v1](post-submission-lifecycle-compatibility-map-v1.md) -- [Venue Adapter Capability Model v1](venue-adapter-capability-model-v1.md) -- [ProcessingContext / EventStreamCursor Contract v1](processing-context-event-stream-cursor-contract-v1.md) -- [EventStreamCursor Characterization Note v1](event-stream-cursor-characterization-v1.md) -- [OrderSubmittedEvent / Dispatch Boundary Contract v1](order-submitted-event-contract-v1.md) -- [Control-Time Event Contract v1](control-time-event-contract-v1.md) - ---- - -## Milestone status snapshot - -### Satisfied in current implementation - -- `EventStreamEntry` minimal contract (`position`, `event`) and call-level configuration input. -- `ProcessingPosition` monotonic canonical boundary ordering. -- `CoreConfiguration` (`version` / `payload` / stable `fingerprint`) with boundary typing. -- Positioned canonical `MarketEvent` path consuming `CoreConfiguration` instrument metadata with explicit-or-fail validation. -- Dispatch-time canonical `OrderSubmittedEvent` boundary for successful `new` dispatch. -- Canonical `ControlTimeEvent` runtime injection on realized scheduled deadline boundary. -- Runtime-only `EventStreamCursor` ordering helper implemented in `core-runtime` and used by strategy runner canonical paths. -- Compatibility boundary guards and semantics coverage remain in place (`OrderStateEvent` and `DerivedFillEvent` non-canonical at canonical boundary). -- Runtime-to-`CoreConfiguration` mapping implemented in `core-runtime` and validated at runtime boundary. - -### Transitional in current implementation - -- `StrategyState` contains canonical reducer paths and compatibility reducer/projection paths concurrently. -- Post-submission lifecycle progression after `Submitted` remains snapshot/compatibility-driven (`ingest_order_snapshots` / `OrderStateEvent` / `apply_order_state_event` / `DerivedFillEvent` projection). -- `ControlTimeEvent` reducer behavior is currently no-op transition slice (no queue/rate/control reducer migration implied). -- hftbacktest capability support is partial in the model: market/submitted/control-time boundaries are wired; execution-feedback source capability remains unsatisfied. - -### Deferred in current implementation - -- Runtime canonical `FillEvent` ingress. -- `ExecutionFeedbackRecordSource` capability satisfaction. -- Full post-submission lifecycle migration to canonical execution-feedback authority. -- Replay/storage/EventStream persistence implementation. -- `ProcessingContext` implementation. -- Full adapter interface abstraction rollout. - ---- - -## Usability statement - -Current usability decision: - -- Usable for current hftbacktest backtests: **Yes**. -- Usable as a transitional semantic milestone: **Yes**. -- Usable as final full canonical Event Stream implementation: **No**. - ---- - -## Test status at closure snapshot - -Requested suite status used for this closure snapshot: - -- `python -m pytest -q core/tests/semantics` -> `193 passed` -- `python -m pytest -q core-runtime/tests` -> `71 passed` - ---- - -## Closure decision - -For this milestone scope, the Semantic Core Upgrade milestone is considered -**closed as a transitional semantic implementation milestone**. - -This closure does not claim final canonical Event Stream completeness and does -not alter deferred gates documented in the execution-feedback, compatibility-map, -and adapter capability contracts. - ---- diff --git a/docs/venue-adapter-capability-model-v1.md b/docs/venue-adapter-capability-model-v1.md deleted file mode 100644 index ffabd5e..0000000 --- a/docs/venue-adapter-capability-model-v1.md +++ /dev/null @@ -1,325 +0,0 @@ -# Venue Adapter Capability Model v1 - ---- - -## Purpose and scope - -This document defines a docs-only, venue-agnostic capability model for Runtime / -Venue Adapter source boundaries used by `core` processing. - -This slice is architecture-boundary documentation only: - -- it does not implement adapter APIs; -- it does not implement canonical `FillEvent` ingress; -- it does not change runtime behavior; -- it does not canonicalize `OrderStateEvent`; -- it does not change `DerivedFillEvent` behavior; -- it does not change snapshot ingestion behavior; -- it does not change reducers or event taxonomy; -- it does not implement replay/storage/`ProcessingContext`/`EventStreamCursor`. - -`VACM-01` - Main `docs` remains the semantic source of truth for Event, -Event Stream, Processing Order, Configuration, State, Intent, Order lifecycle, -determinism, Runtime, and Venue Adapter semantics. - -`VACM-02` - This page is implementation-facing boundary documentation for -capability classification and authority mapping only. It does not redefine -architecture semantics. - -`VACM-03` - `core` remains venue-agnostic. Runtime/adapters expose source -capabilities; `core` consumes canonical Events and explicit configuration -through existing contracts. - -`VACM-04` - This page must remain consistent with: - -- [Core Stable Contract v1](core-stable-contract-v1.md) -- [Post-Submission Lifecycle Compatibility Map v1](post-submission-lifecycle-compatibility-map-v1.md) -- [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md) -- [Runtime/Adapter Execution Feedback Source Contract v1](runtime-adapter-execution-feedback-source-contract-v1.md) - -Normative semantic references from main `docs`: - -- `docs/docs/00-guides/terminology.md` -- `docs/docs/20-concepts/event-model.md` -- `docs/docs/20-concepts/order-lifecycle.md` -- `docs/docs/20-concepts/snapshot-driven-inputs.md` -- `docs/docs/20-concepts/determinism-model.md` - ---- - -## Authority classification model - -`VACM-05` - Adapter/runtime source capabilities are classified by semantic -authority at the canonical boundary: - -- **canonical event capable** -- **compatibility projection only** -- **runtime/internal only** -- **optional future capability** - -`VACM-06` - **canonical event capable** means the capability can provide source -input that may be represented as canonical Event Stream input under positioned -canonical ingestion and global `ProcessingPosition` ordering authority. - -`VACM-07` - **compatibility projection only** means the capability may feed -compatibility materialization/projection paths but must not be treated as -canonical Event Stream authority in this phase. - -`VACM-08` - **runtime/internal only** means the capability is orchestration or -transport behavior and must not be promoted to canonical Event Stream authority -without explicit separate contract changes. - -`VACM-09` - **optional future capability** means the capability is recognized as -architecturally valid but is not currently satisfied for canonical authority and -remains gated by explicit contracts before canonicalization. - -`VACM-10` - Data-field presence alone does not grant canonical authority. -Canonical authority requires eligible source class, deterministic ordering -contract, and boundary eligibility under existing contracts. - ---- - -## Capability categories and authority implications - -This section defines the capability categories in scope and their current -boundary implications. - -### 1) Market input capability - -`VACM-11` - Purpose: provide market observations/snapshots/deltas as Runtime -input that can be represented as canonical `MarketEvent` stream input. - -`VACM-12` - Possible classifications: - -- canonical event capable (current canonical path); -- compatibility projection only (if a specific runtime path uses non-canonical - projection materialization); -- runtime/internal only (for transport plumbing not entering canonical boundary). - -`VACM-13` - Current implication: market capability can produce canonical Event -Stream input when represented through canonical `MarketEvent` boundary handling. - -`VACM-14` - Guardrails/non-goals: - -- no timestamp-derived `ProcessingOrder`; -- no hidden mutable snapshot state outside Event processing; -- no renaming-only promotion of non-canonical snapshot plumbing to canonical - authority. - -### 2) Order submission result boundary capability - -`VACM-15` - Purpose: expose dispatch-success boundary semantics for order-entry -authority (`Submitted`) via canonical `OrderSubmittedEvent`. - -`VACM-16` - Possible classifications: - -- canonical event capable for dispatch-time Submitted entry authority; -- runtime/internal only for outbound command transport details. - -`VACM-17` - Current implication: successful `new` dispatch boundary can produce -canonical `OrderSubmittedEvent`; failed dispatch and non-entry command classes -remain non-entry behaviors per existing contract. - -`VACM-18` - Guardrails/non-goals: - -- no post-submission lifecycle authority is introduced by this capability; -- no reclassification of `OrderSubmittedEvent` as execution feedback; -- no change to existing compatibility sidecar bookkeeping semantics. - -### 3) Order snapshot capability - -`VACM-19` - Purpose: provide order-condition snapshots used by compatibility -materialization/projection paths after submission. - -`VACM-20` - Possible classifications: - -- compatibility projection only (current authority status); -- runtime/internal only (transport/materialization mechanisms). - -`VACM-21` - Current implication: snapshot order capability remains -compatibility-only via `ingest_order_snapshots` / `OrderStateEvent` / -`DerivedFillEvent` projection paths. - -`VACM-22` - Canonical Event Stream production from this capability is not -permitted in this phase for execution-feedback authority. - -`VACM-23` - Guardrails/non-goals: - -- no `OrderStateEvent` canonicalization; -- no snapshot-derived canonical execution feedback promotion; -- no reducer or snapshot lifecycle rewrite in this slice. - -### 4) Account snapshot capability - -`VACM-24` - Purpose: provide account-condition snapshots (balances/positions and -related account views) for runtime and/or compatibility projections. - -`VACM-25` - Possible classifications: - -- compatibility projection only; -- runtime/internal only; -- optional future capability for explicit canonical representation under a - separate contract. - -`VACM-26` - Current implication: account snapshot capability is -compatibility/runtime-internal unless separately and explicitly canonicalized in -future contract work. - -`VACM-27` - Guardrails/non-goals: - -- snapshot naming must not imply canonical authority; -- any future canonicalization must be explicit, versioned, and replay-stable; -- no implicit event taxonomy expansion in this slice. - -### 5) Control-time realization capability - -`VACM-28` - Purpose: realize non-canonical control scheduling obligations into -canonical `ControlTimeEvent` injection boundaries. - -`VACM-29` - Possible classifications: - -- canonical event capable for realized control-time boundaries; -- runtime/internal only for scheduling orchestration mechanics. - -`VACM-30` - Current implication: capability is canonical event capable for the -current sparse scheduled-deadline transition behavior. - -`VACM-31` - Guardrails/non-goals: - -- no periodic control tick introduction; -- no separate runtime tick authority outside Event processing; -- no queue/rate reducer migration introduced here. - -### 6) Execution feedback capability - -`VACM-32` - Purpose: provide authoritative execution-feedback source records that -may enable future canonical `FillEvent` mapping and ingress. - -`VACM-33` - Possible classifications: - -- optional future capability (current primary classification); -- canonical event capable only after explicit gate satisfaction under REFC/RAEFSC; -- runtime/internal only for ineligible signaling paths. - -`VACM-34` - Current implication: canonical `FillEvent` ingress remains deferred. -Snapshot-derived progression and compatibility artifacts remain non-canonical. - -`VACM-35` - Canonical Event Stream production from execution feedback capability -is gated and not enabled by this document. - -`VACM-36` - Guardrails/non-goals: - -- no `FillEvent` ingress implementation; -- no synthetic required-field authority (including `liquidity_flag`); -- no dual-authority fill progression without explicit no-double-counting policy. - ---- - -## Current hftbacktest capability map (Phase 6C snapshot) - -`VACM-37` - This table records current capability support classification for the -hftbacktest adapter/runtime integration without changing behavior. - -| capability | current hftbacktest support | classification | current event/artifact path | notes / limitations | -| --- | --- | --- | --- | --- | -| market input capability | supported | canonical event capable | canonical `MarketEvent` positioned ingestion path | canonical market path active; ordering remains `ProcessingPosition` authority | -| order submission result boundary capability | supported (entry boundary) | canonical event capable | successful `new` dispatch -> canonical `OrderSubmittedEvent` | failed `new` dispatch emits no `OrderSubmittedEvent`; replace/cancel do not create new entry event | -| order snapshot capability | supported | compatibility projection only | `ingest_order_snapshots` -> `OrderStateEvent` -> `apply_order_state_event`; `DerivedFillEvent` projection | post-submission lifecycle remains compatibility authority in current phase | -| account snapshot capability | partially supported as runtime/compatibility views | compatibility projection only / runtime-internal only | runtime/account snapshot views and compatibility materialization where present | not canonical authority unless later explicit canonical contract work | -| control-time realization capability | supported (current transition slice) | canonical event capable | realized deadline obligation -> canonical `ControlTimeEvent` injection | sparse/deadline-style realization only; no periodic tick model | -| execution feedback capability | not supported as authoritative source | optional future capability (currently missing/ineligible) | no eligible `ExecutionFeedbackRecordSource` path in current integration | blocked by missing authoritative source channel, deterministic non-timestamp `source_sequence`, source-authoritative liquidity, and explicit canonical correlation gates | - -`VACM-38` - Current hftbacktest execution-feedback feasibility remains blocked by -the missing authoritative `ExecutionFeedbackRecordSource` capability. - -`VACM-39` - Snapshot compatibility path remains active semantic authority for -post-submission progression in this phase. - ---- - -## Future live venue capability expectations (non-implemented) - -`VACM-40` - A future live venue adapter may expose native execution-report -records that can satisfy `ExecutionFeedbackRecordSource` source-authority -requirements. - -`VACM-41` - A future live venue adapter may expose source-authoritative -liquidity classification (`maker` / `taker` / explicit `unknown`) suitable for -required-field authority. - -`VACM-42` - A future live venue adapter may expose deterministic replay-stable -correlation to canonical order identity (`instrument + client_order_id`), -including explicit successor-mapping chain behavior where applicable. - -`VACM-43` - A future live venue adapter may expose deterministic non-timestamp -`source_sequence` semantics suitable for runner merge policy into global -`ProcessingPosition`. - -`VACM-44` - Canonical runtime `FillEvent` ingress remains gated by REFC/RAEFSC -contracts and is not enabled by capability expectation statements alone. - ---- - -## Canonical vs compatibility implications - -`VACM-45` - Data availability does not equal canonical authority. - -`VACM-46` - Snapshot field availability must not be promoted to canonical -execution-feedback authority without explicit eligible source contract -satisfaction. - -`VACM-47` - Runtime/internal wakeups, signaling hooks, and synchronous return -codes are not canonical Event Stream authority. - -`VACM-48` - Compatibility projection paths remain compatibility authority until -explicit gates are satisfied and separately approved for canonical cutover. - -`VACM-49` - Optional future capabilities require explicit gate satisfaction, -ordering policy, and no-double-counting policy before any canonicalization -planning. - ---- - -## Guardrails - -`VACM-50` - `core` consumes canonical Events and explicit configuration at the -boundary; `core` does not consume venue-specific internal structures as semantic -authority. - -`VACM-51` - Adapter/runtime naming must not promote snapshots or internal -signals to canonical authority by terminology alone. - -`VACM-52` - Execution feedback capability must satisfy REFC/RAEFSC eligibility, -field authority, identity/correlation, deterministic ordering, and -no-double-counting requirements before canonical `FillEvent` ingress planning. - -`VACM-53` - `ProcessingPosition` remains global canonical acceptance-order -authority across canonical categories. - -`VACM-54` - `ProcessingOrder` must not be timestamp-derived. - -`VACM-55` - This model does not alter current canonical/non-canonical taxonomy -or compatibility boundaries in existing contracts. - ---- - -## Explicit non-goals for Phase 6C - -`VACM-56` - No adapter API methods/signatures are defined or implemented. - -`VACM-57` - No hftbacktest-specific `core` semantics are introduced. - -`VACM-58` - No runtime canonical `FillEvent` ingress implementation. - -`VACM-59` - No `OrderStateEvent` canonicalization. - -`VACM-60` - No `DerivedFillEvent` removal or behavior change. - -`VACM-61` - No snapshot lifecycle rewrite. - -`VACM-62` - No reducer or runtime behavior change. - -`VACM-63` - No replay/storage/`ProcessingContext`/`EventStreamCursor` -implementation. - ---- diff --git a/examples/core_step_quickstart.py b/examples/core_step_quickstart.py new file mode 100644 index 0000000..c7187de --- /dev/null +++ b/examples/core_step_quickstart.py @@ -0,0 +1,139 @@ +"""Core-only CoreStep quickstart example. + +For ordered multi-entry wakeup batches see run_core_wakeup_step in the README. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import tradingchassis_core as tc + +INSTRUMENT = "BTC-USDC-PERP" +INTENT_ID_V1 = "quickstart-new-v1" +INTENT_ID_V2 = "quickstart-new-v2" + + +class OneIntentEvaluator: + """Small evaluator that emits one deterministic new-order Intent.""" + + def __init__(self, client_order_id: str) -> None: + self._client_order_id = client_order_id + + def evaluate(self, context: object) -> list[tc.NewOrderIntent]: + _ = context + return [ + tc.NewOrderIntent( + ts_ns_local=1_000, + instrument=INSTRUMENT, + client_order_id=self._client_order_id, + intents_correlation_id=f"corr-{self._client_order_id}", + side="buy", + order_type="limit", + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + ] + + +class AllowAllPolicy: + """Policy evaluator that admits every generated candidate Intent.""" + + def evaluate_policy_intent( + self, + *, + intent: tc.OrderIntent, + state: tc.StrategyState, + now_ts_ns_local: int, + ) -> tuple[bool, str | None]: + _ = (intent, state, now_ts_ns_local) + return True, None + + +def _control_time_entry(*, index: int, ts_ns_local: int) -> tc.EventStreamEntry: + # EventStreamEntry is the ordered Core input unit: a canonical Event plus + # ProcessingPosition telling Core where this Event sits in the Event Stream. + # ControlTimeEvent here is only a driver Event; scheduling obligations come + # from Execution Control apply (e.g. rate-limit deferral), not from every step. + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=tc.ControlTimeEvent( + ts_ns_local_control=ts_ns_local, + reason="scheduled_control_recheck", + due_ts_ns_local=ts_ns_local, + realized_ts_ns_local=ts_ns_local, + obligation_reason="rate_limit", + obligation_due_ts_ns_local=ts_ns_local, + runtime_correlation=None, + ), + ) + + +def run_v1_generated_only(state: tc.StrategyState) -> tc.CoreStepResult: + # v1 shows the minimum deterministic step: Core reduces one canonical Event + # and Strategy evaluation emits generated Intents. No policy/apply contexts + # are provided yet, so Core returns zero dispatchable Intents by design. + result = tc.run_core_step( + state, + _control_time_entry(index=0, ts_ns_local=1_000), + strategy_evaluator=OneIntentEvaluator(INTENT_ID_V1), + ) + assert len(result.generated_intents) == 1 + assert result.generated_intents[0].client_order_id == INTENT_ID_V1 + assert len(result.candidate_intent_records) == 1 + assert result.candidate_intent_records[0].origin is tc.CandidateIntentOrigin.GENERATED + assert result.dispatchable_intents == () + return result + + +def run_v2_with_policy_and_apply(state: tc.StrategyState) -> tc.CoreStepResult: + # v2 adds policy admission and Execution Control apply. With dispatchable + # outputs activated, Core exposes Intents that Runtime can dispatch. + result = tc.run_core_step( + state, + _control_time_entry(index=1, ts_ns_local=1_001), + strategy_evaluator=OneIntentEvaluator(INTENT_ID_V2), + policy_admission_context=tc.CorePolicyAdmissionContext( + policy_evaluator=AllowAllPolicy(), + now_ts_ns_local=1_001, + ), + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=1_001, + activate_dispatchable_outputs=True, + ), + ) + assert len(result.dispatchable_intents) == 1 + assert result.dispatchable_intents[0].client_order_id == INTENT_ID_V2 + return result + + +def main() -> None: + # StrategyState holds deterministic Core memory across steps + # (market snapshots, queued Intents, monotone timestamps, etc.). + state = tc.StrategyState(event_bus=tc.NullEventBus()) + + # Core consumes canonical Events. Here we use ControlTimeEvent as a simple + # canonical trigger Event to drive the deterministic step pipeline. + result_v1 = run_v1_generated_only(state) + result_v2 = run_v2_with_policy_and_apply(state) + + print("CoreStep quickstart (Core-only deterministic engine)") + print("v1 generated:", [Intent.client_order_id for Intent in result_v1.generated_intents]) + print( + "v1 candidate origins:", + [record.origin.value for record in result_v1.candidate_intent_records], + ) + print("v1 dispatchable: [] (Core does not dispatch externally)") + print("v2 dispatchable:", [Intent.client_order_id for Intent in result_v2.dispatchable_intents]) + print("v2 obligation:", result_v2.control_scheduling_obligation) + print("Runtime dispatches these; Core only returns decisions/Intents.") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 4589da3..c7b6052 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,18 +3,23 @@ requires = ["setuptools>=69", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "tradingchassis-core" +name = "TradingChassis-core" version = "0.1.0" -description = "Deterministic, event-driven core, with explicit risk management, order state machines, queue semantics, and research orchestration." +description = "Deterministic trading decision kernel." readme = "README.md" requires-python = ">=3.11" -authors = [{ name = "tradingeng@protonmail.com" }] -license = { text = "MIT" } +authors = [{ name = "TradingChassis Core Contributors" }] +maintainers = [{ name = "TradingChassis Core Maintainers" }] +license = "MIT" +keywords = ["trading", "deterministic", "pydantic", "event-driven", "strategy"] classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Topic :: Office/Business :: Financial :: Investment", + "Topic :: Software Development :: Libraries", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", - "License :: OSI Approved :: MIT License", "Operating System :: OS Independent" ] @@ -28,30 +33,25 @@ dev = [ "import-linter>=1.11,<2", "ruff>=0.4,<1", "mypy>=1.9,<2", - "hftbacktest>=2,<3", - "jsonschema>=4,<5", - "matplotlib>=3,<4", - "numpy>=2.0,<2.3", - "referencing>=0.37,<1", + "build>=1,<2", ] -# -------------------------------------------------- -# Explicit package discovery -# -------------------------------------------------- +[project.urls] +Homepage = "https://github.com/TradingChassis" +Repository = "https://github.com/TradingChassis/core" +Documentation = "https://github.com/TradingChassis/core/tree/main/core/docs" +Changelog = "https://github.com/TradingChassis/core/blob/main/core/CHANGELOG.md" +Issues = "https://github.com/TradingChassis/core/issues" + [tool.setuptools.packages.find] include = ["tradingchassis_core*"] -# -------------------------------------------------- -# Pytest -# -------------------------------------------------- [tool.pytest.ini_options] minversion = "9.0" addopts = "-ra" testpaths = ["tests"] +pythonpath = ["."] -# -------------------------------------------------- -# Ruff -# -------------------------------------------------- [tool.ruff] target-version = "py311" line-length = 100 @@ -60,30 +60,19 @@ line-length = 100 select = ["E", "F", "I"] ignore = ["E501"] -# -------------------------------------------------- -# MyPy (static typing) -# -------------------------------------------------- [tool.mypy] python_version = "3.11" warn_unused_configs = true ignore_missing_imports = true pretty = true show_error_codes = true -ignore_errors = true -# -------------------------------------------------- -# Import Linter -# -------------------------------------------------- [tool.importlinter] root_package = "tradingchassis_core" include_external_packages = true -# Core stays pure [[tool.importlinter.contracts]] -name = "Core must be pure" +name = "Core stays runtime-independent" type = "forbidden" -source_modules = ["tradingchassis_core.core"] -forbidden_modules = [ - "tradingchassis_core.strategies" -] - +source_modules = ["tradingchassis_core"] +forbidden_modules = [] diff --git a/tests/semantics/gate_risk_invariants/test_cancel_non_existing_rejected.py b/tests/semantics/gate_risk_invariants/test_cancel_non_existing_rejected.py deleted file mode 100644 index b346d93..0000000 --- a/tests/semantics/gate_risk_invariants/test_cancel_non_existing_rejected.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -Semantic test: CANCEL intent for a non-existing order must be rejected. - -Invariant: -A CANCEL intent requires an existing order with the same -(instrument, client_order_id). Otherwise it must be rejected. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.reject_reasons import RejectReason -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - CancelOrderIntent, - NotionalLimits, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def test_cancel_for_non_existing_order_is_rejected() -> None: - """Reject CANCEL intent when no order with the given id exists.""" - - instrument = "BTC-USDC-PERP" - client_order_id = "missing-order" - - state = StrategyState(event_bus=NullEventBus()) - - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - extra={}, - ) - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - cancel_intent = CancelOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - ) - - decision = risk_engine.decide_intents( - raw_intents=[cancel_intent], - state=state, - now_ts_ns_local=1, - ) - - # ---------- assert decision ---------- - assert decision.accepted_now == [] - assert decision.queued == [] - assert decision.handled_in_queue == [] - assert len(decision.rejected) == 1 - assert decision.rejected[0].reason == RejectReason.ORDER_NOT_FOUND - - # ---------- assert no side effects ---------- - assert not state.has_working_order(instrument, client_order_id) - assert not state.has_queued_intent(instrument, client_order_id) diff --git a/tests/semantics/gate_risk_invariants/test_duplicate_new_rejected.py b/tests/semantics/gate_risk_invariants/test_duplicate_new_rejected.py deleted file mode 100644 index 2c531fe..0000000 --- a/tests/semantics/gate_risk_invariants/test_duplicate_new_rejected.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -Semantic test: Duplicate NEW intent must be rejected. - -Invariant: -(client_order_id, instrument) must be unique while an order id is busy -(working ∪ queued). A NEW with the same id must be rejected. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.reject_reasons import RejectReason -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - NewOrderIntent, - NotionalLimits, - OrderStateEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def test_duplicate_new_is_rejected_when_working_order_exists() -> None: - """Reject NEW intent when a working order with same (instrument, client_order_id) exists.""" - - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # Create an existing WORKING order in state via canonical snapshot ingestion. - existing_order = OrderStateEvent( - ts_ns_exch=1, - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type="working", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - state.apply_order_state_event(existing_order) - - # Minimal risk config (notional_limits is required by validation). - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - extra={}, - ) - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - duplicate_new = NewOrderIntent( - ts_ns_local=2, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=101.0), - time_in_force="GTC", - ) - - decision = risk_engine.decide_intents( - raw_intents=[duplicate_new], - state=state, - now_ts_ns_local=2, - ) - - # Must be rejected with DUPLICATE_ID. - assert decision.accepted_now == [] - assert decision.queued == [] - assert decision.handled_in_queue == [] - assert len(decision.rejected) == 1 - assert decision.rejected[0].reason == RejectReason.DUPLICATE_ID - - # State must remain: working still exists; no queued intent for same id. - assert state.has_working_order(instrument, client_order_id) - assert not state.has_queued_intent(instrument, client_order_id) diff --git a/tests/semantics/gate_risk_invariants/test_inflight_blocks_replace.py b/tests/semantics/gate_risk_invariants/test_inflight_blocks_replace.py deleted file mode 100644 index 2d5c62c..0000000 --- a/tests/semantics/gate_risk_invariants/test_inflight_blocks_replace.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -Semantic test: inflight intent blocks further REPLACE intents. - -Invariant: -While an order id is inflight, no additional REPLACE -may be sent. Such intents must be queued instead. - -NEW with the same client_order_id is always rejected. -CANCEL is always allowed. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - NotionalLimits, - OrderStateEvent, - Price, - Quantity, - ReplaceOrderIntent, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def test_inflight_blocks_new_intent_and_queues_it() -> None: - """NEW intent must be queued when an inflight action exists for the same id.""" - - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # Existing working order - existing_order = OrderStateEvent( - ts_ns_exch=1, - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type="working", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 1, "source": "snapshot"}, - ) - state.apply_order_state_event(existing_order) - - # Simulate inflight action (previous replace already sent) - state.mark_intent_sent( - instrument=instrument, - client_order_id=client_order_id, - intent_type="replace", - ) - - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - extra={}, - ) - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - replace_intent = ReplaceOrderIntent( - ts_ns_local=2, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - side="buy", - intended_price=Price(currency="USDC", value=101.0), - intended_qty=Quantity(unit="contracts", value=1.0), - ) - - decision = risk_engine.decide_intents( - raw_intents=[replace_intent], - state=state, - now_ts_ns_local=2, - ) - - # ---------- assert decision ---------- - assert decision.accepted_now == [] - assert decision.rejected == [] - assert decision.handled_in_queue == [] - assert len(decision.queued) == 1 - - # ---------- assert state ---------- - assert state.has_working_order(instrument, client_order_id) - assert state.has_queued_intent(instrument, client_order_id) diff --git a/tests/semantics/gate_risk_invariants/test_rejected_intents_do_not_enter_queue_characterization.py b/tests/semantics/gate_risk_invariants/test_rejected_intents_do_not_enter_queue_characterization.py deleted file mode 100644 index 8460b73..0000000 --- a/tests/semantics/gate_risk_invariants/test_rejected_intents_do_not_enter_queue_characterization.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -Characterization test: rejected/denied intents must not enter the queue. - -This pins current behavior that hard rejects do not mutate StrategyState.queued_intents. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.reject_reasons import RejectReason -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - NewOrderIntent, - NotionalLimits, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def test_trading_disabled_rejects_new_without_queue_side_effects_characterization() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - risk_cfg = RiskConfig( - scope="test", - trading_enabled=False, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - ) - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - new_intent = NewOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - decision = risk_engine.decide_intents( - raw_intents=[new_intent], - state=state, - now_ts_ns_local=1, - ) - - assert decision.accepted_now == [] - assert decision.queued == [] - assert decision.handled_in_queue == [] - assert len(decision.rejected) == 1 - assert decision.rejected[0].reason == RejectReason.TRADING_DISABLED - - assert not state.has_queued_intent(instrument, client_order_id) diff --git a/tests/semantics/gate_risk_invariants/test_replace_noop_handled.py b/tests/semantics/gate_risk_invariants/test_replace_noop_handled.py deleted file mode 100644 index fdd95c7..0000000 --- a/tests/semantics/gate_risk_invariants/test_replace_noop_handled.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -Semantic test: REPLACE intent with no effective change must be handled as no-op. - -Invariant: -A REPLACE that does not change price or quantity after normalization -must not be sent, queued, or rejected. It must be handled as no-op. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - NotionalLimits, - OrderStateEvent, - Price, - Quantity, - ReplaceOrderIntent, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def test_replace_without_effective_change_is_handled_noop() -> None: - """REPLACE with identical price/qty must be handled and dropped.""" - - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # Existing working order - existing_order = OrderStateEvent( - ts_ns_exch=1, - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type="working", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - state.apply_order_state_event(existing_order) - - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - extra={}, - ) - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - replace_noop = ReplaceOrderIntent( - ts_ns_local=2, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - side="buy", - intended_price=Price(currency="USDC", value=100.0), - intended_qty=Quantity(unit="contracts", value=1.0), - ) - - decision = risk_engine.decide_intents( - raw_intents=[replace_noop], - state=state, - now_ts_ns_local=2, - ) - - # ---------- assert decision ---------- - assert decision.accepted_now == [] - assert decision.queued == [] - assert decision.rejected == [] - assert len(decision.handled_in_queue) == 1 - - # ---------- assert no side effects ---------- - assert state.has_working_order(instrument, client_order_id) - assert not state.has_queued_intent(instrument, client_order_id) diff --git a/tests/semantics/models/test_canonical_processing_boundary.py b/tests/semantics/models/test_canonical_processing_boundary.py deleted file mode 100644 index b7bda90..0000000 --- a/tests/semantics/models/test_canonical_processing_boundary.py +++ /dev/null @@ -1,848 +0,0 @@ -"""Semantics tests for the minimal canonical processing boundary.""" - -from __future__ import annotations - -import copy - -import pytest - -from tradingchassis_core.core.domain.configuration import CoreConfiguration -from tradingchassis_core.core.domain.event_model import is_canonical_stream_candidate_type -from tradingchassis_core.core.domain.processing import process_canonical_event, process_event_entry -from tradingchassis_core.core.domain.processing_order import EventStreamEntry, ProcessingPosition -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - ControlTimeEvent, - FillEvent, - MarketEvent, - OrderStateEvent, - OrderSubmittedEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.event_bus import EventBus -from tradingchassis_core.core.events.events import DerivedFillEvent, RiskDecisionEvent -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def _state_subset_snapshot(state: StrategyState) -> dict[str, object]: - return { - "market": copy.deepcopy(state.market), - "fills": copy.deepcopy(state.fills), - "fill_cum_qty": copy.deepcopy(state.fill_cum_qty), - } - - -def _book_market_event( - *, - instrument: str, - ts_ns_local: int, - ts_ns_exch: int, - best_bid: float = 100.0, - best_ask: float = 101.0, - best_bid_qty: float = 2.0, - best_ask_qty: float = 3.0, -) -> MarketEvent: - return MarketEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - event_type="book", - book={ - "book_type": "snapshot", - "bids": [ - { - "price": {"currency": "USDC", "value": best_bid}, - "quantity": {"unit": "contracts", "value": best_bid_qty}, - } - ], - "asks": [ - { - "price": {"currency": "USDC", "value": best_ask}, - "quantity": {"unit": "contracts", "value": best_ask_qty}, - } - ], - "depth": 1, - }, - trade=None, - ) - - -def _fill_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local: int, - ts_ns_exch: int, - cum_filled_qty: float = 0.25, -) -> FillEvent: - return FillEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=Price(currency="USDC", value=100.5), - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=Quantity(unit="contracts", value=cum_filled_qty), - remaining_qty=Quantity(unit="contracts", value=max(0.0, 1.0 - cum_filled_qty)), - time_in_force="GTC", - liquidity_flag="maker", - fee=None, - ) - - -def _order_state_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local: int, - ts_ns_exch: int, - state_type: str = "accepted", -) -> OrderStateEvent: - return OrderStateEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type=state_type, - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - - -def _order_submitted_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local_dispatch: int, -) -> OrderSubmittedEvent: - return OrderSubmittedEvent( - ts_ns_local_dispatch=ts_ns_local_dispatch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - order_type="limit", - intended_price=Price(currency="USDC", value=100.0), - intended_qty=Quantity(unit="contracts", value=1.0), - time_in_force="GTC", - intent_correlation_id="corr-1", - dispatch_attempt_id="attempt-1", - runtime_correlation={"engine": "backtest", "seq": 1}, - ) - - -def _control_time_event( - *, - ts_ns_local_control: int, - reason: str = "rate_limit_recheck", - due_ts_ns_local: int | None = None, - realized_ts_ns_local: int | None = None, -) -> ControlTimeEvent: - return ControlTimeEvent( - ts_ns_local_control=ts_ns_local_control, - reason=reason, - due_ts_ns_local=due_ts_ns_local, - realized_ts_ns_local=realized_ts_ns_local, - obligation_reason="rate_limit", - obligation_due_ts_ns_local=due_ts_ns_local, - runtime_correlation={"engine": "backtest", "seq": 1}, - ) - - -def _market_configuration( - *, - instrument: str = "BTC-USDC-PERP", - tick_size: float = 0.1, - lot_size: float = 0.01, - contract_size: float = 1.0, -) -> CoreConfiguration: - return CoreConfiguration( - version="v1", - payload={ - "market": { - "instruments": { - instrument: { - "tick_size": tick_size, - "lot_size": lot_size, - "contract_size": contract_size, - } - } - } - }, - ) - - -def _control_state_snapshot(state: StrategyState) -> dict[str, object]: - return { - "queued_intents": copy.deepcopy(state.queued_intents), - "inflight": copy.deepcopy(state.inflight), - "orders": copy.deepcopy(state.orders), - "canonical_orders": copy.deepcopy(state.canonical_orders), - "fills": copy.deepcopy(state.fills), - "market": copy.deepcopy(state.market), - "account": copy.deepcopy(state.account), - } - - -def test_process_canonical_event_accepts_market_event() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - - process_canonical_event(state, event) - - market = state.market["BTC-USDC-PERP"] - assert market.last_ts_ns_local == 100 - assert market.last_ts_ns_exch == 90 - assert market.best_bid == 100.0 - assert market.best_ask == 101.0 - assert market.best_bid_qty == 2.0 - assert market.best_ask_qty == 3.0 - assert market.mid == 100.5 - - -def test_process_canonical_event_accepts_market_event_with_processing_position() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - position = ProcessingPosition(index=5) - - process_canonical_event(state, event, position=position, configuration=_market_configuration()) - - market = state.market["BTC-USDC-PERP"] - assert market.last_ts_ns_local == 100 - assert market.last_ts_ns_exch == 90 - assert market.best_bid == 100.0 - assert market.best_ask == 101.0 - assert market.best_bid_qty == 2.0 - assert market.best_ask_qty == 3.0 - assert market.mid == 100.5 - assert state._last_processing_position_index == 5 - - -def test_process_canonical_event_accepts_fill_event() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=200, - ts_ns_exch=180, - ) - - process_canonical_event(state, event) - - fills = state.fills["BTC-USDC-PERP"] - assert len(fills) == 1 - assert fills[0] == event - assert state.fill_cum_qty["BTC-USDC-PERP"]["order-1"] == 0.25 - - -def test_process_canonical_event_accepts_fill_event_with_processing_position() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=200, - ts_ns_exch=180, - ) - position = ProcessingPosition(index=12) - - process_canonical_event(state, event, position=position) - - fills = state.fills["BTC-USDC-PERP"] - assert len(fills) == 1 - assert fills[0] == event - assert state.fill_cum_qty["BTC-USDC-PERP"]["order-1"] == 0.25 - assert state._last_processing_position_index == 12 - - -def test_process_canonical_event_accepts_order_submitted_event() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _order_submitted_event( - instrument="BTC-USDC-PERP", - client_order_id="order-submitted-1", - ts_ns_local_dispatch=300, - ) - - process_canonical_event(state, event) - - projection = state.canonical_orders[("BTC-USDC-PERP", "order-submitted-1")] - assert projection.state == "submitted" - assert projection.submitted_ts_ns_local == 300 - assert projection.updated_ts_ns_local == 300 - - -def test_process_canonical_event_accepts_order_submitted_event_with_processing_position() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _order_submitted_event( - instrument="BTC-USDC-PERP", - client_order_id="order-submitted-1", - ts_ns_local_dispatch=300, - ) - - process_canonical_event(state, event, position=ProcessingPosition(index=13)) - - projection = state.canonical_orders[("BTC-USDC-PERP", "order-submitted-1")] - assert projection.state == "submitted" - assert projection.submitted_ts_ns_local == 300 - assert projection.updated_ts_ns_local == 300 - assert state._last_processing_position_index == 13 - - -def test_control_time_event_requires_due_or_realized_timestamp() -> None: - with pytest.raises( - ValueError, - match="at least one of due_ts_ns_local or realized_ts_ns_local is required", - ): - _control_time_event(ts_ns_local_control=500) - - -def test_control_time_event_rejects_extra_fields() -> None: - with pytest.raises(ValueError, match="Extra inputs are not permitted"): - ControlTimeEvent( - ts_ns_local_control=501, - reason="rate_limit_recheck", - due_ts_ns_local=600, - extra_field="unexpected", - ) - - -def test_process_canonical_event_accepts_control_time_event_with_processing_position() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _control_time_event( - ts_ns_local_control=510, - due_ts_ns_local=520, - ) - - process_canonical_event(state, event, position=ProcessingPosition(index=14)) - - assert state._last_processing_position_index == 14 - - -def test_process_canonical_event_control_time_event_does_not_mutate_state_buckets() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _control_time_event( - ts_ns_local_control=530, - realized_ts_ns_local=531, - ) - before = _control_state_snapshot(state) - - process_canonical_event(state, event, position=ProcessingPosition(index=15)) - - after = _control_state_snapshot(state) - assert after == before - assert state._last_processing_position_index == 15 - - -def test_control_time_event_still_obeys_global_processing_position_monotonicity() -> None: - state = StrategyState(event_bus=NullEventBus()) - first = _control_time_event( - ts_ns_local_control=540, - due_ts_ns_local=550, - ) - repeated = _control_time_event( - ts_ns_local_control=541, - due_ts_ns_local=551, - ) - - process_canonical_event(state, first, position=ProcessingPosition(index=16)) - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - process_canonical_event(state, repeated, position=ProcessingPosition(index=16)) - - assert state._last_processing_position_index == 16 - - -def test_first_positioned_event_is_accepted() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - - process_canonical_event( - state, - event, - position=ProcessingPosition(index=0), - configuration=_market_configuration(), - ) - - assert state._last_processing_position_index == 0 - - -def test_increasing_positions_are_accepted() -> None: - state = StrategyState(event_bus=NullEventBus()) - first = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - second = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=101, - ts_ns_exch=91, - ) - - process_canonical_event( - state, - first, - position=ProcessingPosition(index=10), - configuration=_market_configuration(), - ) - process_canonical_event(state, second, position=ProcessingPosition(index=11)) - - assert state._last_processing_position_index == 11 - - -def test_repeated_position_is_rejected_without_state_mutation() -> None: - state = StrategyState(event_bus=NullEventBus()) - accepted = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - rejected = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=101, - ts_ns_exch=91, - ) - - process_canonical_event( - state, - accepted, - position=ProcessingPosition(index=3), - configuration=_market_configuration(), - ) - before = _state_subset_snapshot(state) - - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - process_canonical_event(state, rejected, position=ProcessingPosition(index=3)) - - after = _state_subset_snapshot(state) - assert after == before - assert state._last_processing_position_index == 3 - - -def test_regressing_position_is_rejected_without_state_mutation() -> None: - state = StrategyState(event_bus=NullEventBus()) - accepted = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - rejected = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=102, - ts_ns_exch=92, - ) - - process_canonical_event( - state, - accepted, - position=ProcessingPosition(index=8), - configuration=_market_configuration(), - ) - before = _state_subset_snapshot(state) - - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - process_canonical_event(state, rejected, position=ProcessingPosition(index=7)) - - after = _state_subset_snapshot(state) - assert after == before - assert state._last_processing_position_index == 8 - - -def test_position_none_remains_allowed_and_does_not_advance_cursor() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - - process_canonical_event(state, event, position=None) - - assert state._last_processing_position_index is None - - positioned = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=101, - ts_ns_exch=91, - ) - process_canonical_event(state, positioned, position=ProcessingPosition(index=0)) - assert state._last_processing_position_index == 0 - - -def test_processing_position_is_not_derived_from_event_time() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=1_000_000, ts_ns_exch=900_000) - position = ProcessingPosition(index=1) - - process_canonical_event(state, event, position=position, configuration=_market_configuration()) - - market = state.market["BTC-USDC-PERP"] - assert market.last_ts_ns_local == event.ts_ns_local - assert market.last_ts_ns_exch == event.ts_ns_exch - - -def test_event_time_out_of_order_but_position_increasing_is_accepted_at_boundary() -> None: - state = StrategyState(event_bus=NullEventBus()) - first = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=200, ts_ns_exch=190) - second = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=95) - - configuration = _market_configuration() - process_canonical_event(state, first, position=ProcessingPosition(index=1), configuration=configuration) - process_canonical_event(state, second, position=ProcessingPosition(index=2), configuration=configuration) - - assert state._last_processing_position_index == 2 - # Positioned canonical market events are now ProcessingPosition-driven. - market = state.market["BTC-USDC-PERP"] - assert market.last_ts_ns_local == 100 - assert market.last_ts_ns_exch == 95 - - -def test_position_out_of_order_but_event_time_increasing_is_rejected_at_boundary() -> None: - state = StrategyState(event_bus=NullEventBus()) - first = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - second = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=200, - ts_ns_exch=180, - ) - - process_canonical_event( - state, - first, - position=ProcessingPosition(index=5), - configuration=_market_configuration(), - ) - before = _state_subset_snapshot(state) - - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - process_canonical_event(state, second, position=ProcessingPosition(index=4)) - - after = _state_subset_snapshot(state) - assert after == before - assert state._last_processing_position_index == 5 - - -@pytest.mark.parametrize("second_cum_filled_qty", [0.25, 0.20]) -def test_positioned_fill_ordering_divergence_advances_cursor_but_keeps_fill_state_idempotent( - second_cum_filled_qty: float, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - first = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=200, - ts_ns_exch=180, - cum_filled_qty=0.25, - ) - second = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=201, - ts_ns_exch=181, - cum_filled_qty=second_cum_filled_qty, - ) - - process_canonical_event(state, first, position=ProcessingPosition(index=20)) - fills_before = copy.deepcopy(state.fills) - fill_cum_before = copy.deepcopy(state.fill_cum_qty) - - process_canonical_event(state, second, position=ProcessingPosition(index=21)) - - assert state._last_processing_position_index == 21 - assert state.fills == fills_before - assert state.fill_cum_qty == fill_cum_before - assert len(state.fills["BTC-USDC-PERP"]) == 1 - assert state.fill_cum_qty["BTC-USDC-PERP"]["order-1"] == 0.25 - - -def test_interleaved_positioned_and_unpositioned_processing_preserves_cursor_monotonicity() -> None: - state = StrategyState(event_bus=NullEventBus()) - positioned_10 = _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=100, - ts_ns_exch=90, - ) - unpositioned = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=101, - ts_ns_exch=91, - cum_filled_qty=0.25, - ) - positioned_11 = _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=102, - ts_ns_exch=92, - ) - rejected = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=103, - ts_ns_exch=93, - cum_filled_qty=0.50, - ) - - configuration = _market_configuration() - process_canonical_event( - state, - positioned_10, - position=ProcessingPosition(index=10), - configuration=configuration, - ) - assert state._last_processing_position_index == 10 - - process_canonical_event(state, unpositioned, position=None) - assert state._last_processing_position_index == 10 - - process_canonical_event( - state, - positioned_11, - position=ProcessingPosition(index=11), - configuration=configuration, - ) - assert state._last_processing_position_index == 11 - - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - process_canonical_event(state, rejected, position=ProcessingPosition(index=10)) - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - process_canonical_event(state, rejected, position=ProcessingPosition(index=11)) - - assert state._last_processing_position_index == 11 - - -def test_positioned_market_tiebreak_no_longer_gates_positioned_market_updates() -> None: - state = StrategyState(event_bus=NullEventBus()) - base = _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=300, - ts_ns_exch=200, - best_bid=100.0, - best_ask=101.0, - ) - lower_exch = _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=300, - ts_ns_exch=199, - best_bid=80.0, - best_ask=81.0, - ) - higher_exch = _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=300, - ts_ns_exch=201, - best_bid=120.0, - best_ask=121.0, - ) - - configuration = _market_configuration() - process_canonical_event(state, base, position=ProcessingPosition(index=30), configuration=configuration) - process_canonical_event( - state, - lower_exch, - position=ProcessingPosition(index=31), - configuration=configuration, - ) - - market = state.market["BTC-USDC-PERP"] - assert state._last_processing_position_index == 31 - assert market.last_ts_ns_local == 300 - assert market.last_ts_ns_exch == 199 - assert market.best_bid == 80.0 - assert market.best_ask == 81.0 - - process_canonical_event( - state, - higher_exch, - position=ProcessingPosition(index=32), - configuration=configuration, - ) - - market_after_higher = state.market["BTC-USDC-PERP"] - assert state._last_processing_position_index == 32 - assert market_after_higher.last_ts_ns_local == 300 - assert market_after_higher.last_ts_ns_exch == 201 - assert market_after_higher.best_bid == 120.0 - assert market_after_higher.best_ask == 121.0 - - -def test_valid_processing_position_can_authorize_boundary_order_while_reducer_noops() -> None: - """Valid ProcessingPosition advances causal boundary while reducer may still no-op.""" - state = StrategyState(event_bus=NullEventBus()) - first = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=400, - ts_ns_exch=390, - cum_filled_qty=0.40, - ) - duplicate = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=401, - ts_ns_exch=391, - cum_filled_qty=0.40, - ) - - process_canonical_event(state, first, position=ProcessingPosition(index=40)) - fills_before = copy.deepcopy(state.fills) - fill_cum_before = copy.deepcopy(state.fill_cum_qty) - - process_canonical_event(state, duplicate, position=ProcessingPosition(index=41)) - - assert state._last_processing_position_index == 41 - assert state.fills == fills_before - assert state.fill_cum_qty == fill_cum_before - - -def test_positioned_order_submitted_duplicate_is_idempotent_while_cursor_advances() -> None: - state = StrategyState(event_bus=NullEventBus()) - first = _order_submitted_event( - instrument="BTC-USDC-PERP", - client_order_id="order-submitted-dup-1", - ts_ns_local_dispatch=700, - ) - duplicate = _order_submitted_event( - instrument="BTC-USDC-PERP", - client_order_id="order-submitted-dup-1", - ts_ns_local_dispatch=701, - ) - - process_canonical_event(state, first, position=ProcessingPosition(index=42)) - projection_before = copy.deepcopy( - state.canonical_orders[("BTC-USDC-PERP", "order-submitted-dup-1")] - ) - - process_canonical_event(state, duplicate, position=ProcessingPosition(index=43)) - - projection_after = state.canonical_orders[("BTC-USDC-PERP", "order-submitted-dup-1")] - assert state._last_processing_position_index == 43 - assert projection_after == projection_before - - -def test_order_submitted_event_does_not_regress_existing_canonical_state() -> None: - state = StrategyState(event_bus=NullEventBus()) - key = ("BTC-USDC-PERP", "order-no-regress-1") - first = _order_submitted_event( - instrument=key[0], - client_order_id=key[1], - ts_ns_local_dispatch=800, - ) - accepted = _fill_event( - instrument=key[0], - client_order_id=key[1], - ts_ns_local=810, - ts_ns_exch=805, - cum_filled_qty=0.25, - ) - late_submitted = _order_submitted_event( - instrument=key[0], - client_order_id=key[1], - ts_ns_local_dispatch=820, - ) - - process_canonical_event(state, first, position=ProcessingPosition(index=50)) - state.apply_order_state_event( - _order_state_event( - instrument=key[0], - client_order_id=key[1], - ts_ns_local=815, - ts_ns_exch=815, - state_type="accepted", - ) - ) - process_canonical_event(state, accepted, position=ProcessingPosition(index=51)) - process_canonical_event(state, late_submitted, position=ProcessingPosition(index=52)) - - projection = state.canonical_orders[key] - assert projection.state == "accepted" - assert projection.submitted_ts_ns_local == 800 - assert projection.updated_ts_ns_local == 815 - assert state._last_processing_position_index == 52 - - -def test_order_submitted_event_does_not_mutate_snapshot_orders() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _order_submitted_event( - instrument="BTC-USDC-PERP", - client_order_id="order-snapshot-isolation-1", - ts_ns_local_dispatch=900, - ) - - process_canonical_event(state, event, position=ProcessingPosition(index=60)) - - assert state.orders == {} - assert state.canonical_orders[("BTC-USDC-PERP", "order-snapshot-isolation-1")].state == "submitted" - - -def test_process_canonical_event_rejects_order_state_event() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _order_state_event( - instrument="BTC-USDC-PERP", - client_order_id="order-compat-1", - ts_ns_local=300, - ts_ns_exch=290, - ) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, event) - - -def test_process_canonical_event_rejects_order_state_event_with_processing_position() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _order_state_event( - instrument="BTC-USDC-PERP", - client_order_id="order-compat-1", - ts_ns_local=300, - ts_ns_exch=290, - ) - position = ProcessingPosition(index=20) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, event, position=position) - - -def test_process_event_entry_rejects_derived_fill_event() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = DerivedFillEvent( - ts_ns_local=300, - instrument="BTC-USDC-PERP", - client_order_id="order-compat-derived-1", - side="buy", - delta_qty=0.25, - cum_qty=0.25, - price=100.0, - ) - entry = EventStreamEntry( - position=ProcessingPosition(index=21), - event=event, - ) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_event_entry(state, entry) - - -def test_process_canonical_event_rejects_telemetry_record() -> None: - state = StrategyState(event_bus=NullEventBus()) - telemetry = RiskDecisionEvent( - ts_ns_local=400, - accepted=1, - queued=0, - rejected=0, - handled=0, - reject_reasons={}, - ) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, telemetry) - - -def test_event_bus_remains_non_canonical() -> None: - assert is_canonical_stream_candidate_type(EventBus) is False - - -def test_processing_position_zero_index_is_valid() -> None: - position = ProcessingPosition(index=0) - assert position.index == 0 - - -def test_processing_position_negative_index_is_rejected() -> None: - with pytest.raises(ValueError, match="must be non-negative"): - ProcessingPosition(index=-1) - diff --git a/tests/semantics/models/test_canonical_processing_differential_harness.py b/tests/semantics/models/test_canonical_processing_differential_harness.py deleted file mode 100644 index 85b3102..0000000 --- a/tests/semantics/models/test_canonical_processing_differential_harness.py +++ /dev/null @@ -1,362 +0,0 @@ -"""Differential characterization tests for canonical reducer boundary parity.""" - -from __future__ import annotations - -import copy - -import pytest - -from tradingchassis_core.core.domain.processing import process_canonical_event -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - FillEvent, - MarketEvent, - OrderStateEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.events import RiskDecisionEvent -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def _book_market_event( - *, - instrument: str, - ts_ns_local: int, - ts_ns_exch: int, - best_bid: float, - best_ask: float, - best_bid_qty: float = 2.0, - best_ask_qty: float = 3.0, -) -> MarketEvent: - return MarketEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - event_type="book", - book={ - "book_type": "snapshot", - "bids": [ - { - "price": {"currency": "USDC", "value": best_bid}, - "quantity": {"unit": "contracts", "value": best_bid_qty}, - } - ], - "asks": [ - { - "price": {"currency": "USDC", "value": best_ask}, - "quantity": {"unit": "contracts", "value": best_ask_qty}, - } - ], - "depth": 1, - }, - trade=None, - ) - - -def _apply_market_direct(state: StrategyState, event: MarketEvent) -> None: - assert event.book is not None - best_bid_level = event.book.bids[0] - best_ask_level = event.book.asks[0] - state.update_market( - instrument=event.instrument, - best_bid=best_bid_level.price.value, - best_ask=best_ask_level.price.value, - best_bid_qty=best_bid_level.quantity.value, - best_ask_qty=best_ask_level.quantity.value, - tick_size=0.0, - lot_size=0.0, - contract_size=1.0, - ts_ns_local=event.ts_ns_local, - ts_ns_exch=event.ts_ns_exch, - ) - - -def _fill_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local: int, - ts_ns_exch: int, - cum_qty: float, -) -> FillEvent: - remaining = max(0.0, 1.0 - cum_qty) - return FillEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=Price(currency="USDC", value=100.5), - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=Quantity(unit="contracts", value=cum_qty), - remaining_qty=Quantity(unit="contracts", value=remaining), - time_in_force="GTC", - liquidity_flag="maker", - fee=None, - ) - - -def _order_state_event(*, instrument: str, client_order_id: str) -> OrderStateEvent: - return OrderStateEvent( - ts_ns_local=300, - ts_ns_exch=290, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type="accepted", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - - -def _state_subset_snapshot(state: StrategyState) -> dict[str, object]: - return { - "market": copy.deepcopy(state.market), - "fills": copy.deepcopy(state.fills), - "fill_cum_qty": copy.deepcopy(state.fill_cum_qty), - "orders": copy.deepcopy(state.orders), - "canonical_orders": copy.deepcopy(state.canonical_orders), - } - - -def test_market_parity_single_event_canonical_equals_direct() -> None: - instrument = "BTC-USDC-PERP" - event = _book_market_event( - instrument=instrument, - ts_ns_local=100, - ts_ns_exch=90, - best_bid=100.0, - best_ask=101.0, - ) - - canonical_state = StrategyState(event_bus=NullEventBus()) - direct_state = StrategyState(event_bus=NullEventBus()) - - process_canonical_event(canonical_state, event) - _apply_market_direct(direct_state, event) - - assert canonical_state.market == direct_state.market - - -def test_market_parity_newer_local_timestamp_replaces_older() -> None: - instrument = "BTC-USDC-PERP" - older = _book_market_event( - instrument=instrument, - ts_ns_local=100, - ts_ns_exch=90, - best_bid=100.0, - best_ask=101.0, - ) - newer = _book_market_event( - instrument=instrument, - ts_ns_local=101, - ts_ns_exch=80, - best_bid=102.0, - best_ask=103.0, - ) - - canonical_state = StrategyState(event_bus=NullEventBus()) - direct_state = StrategyState(event_bus=NullEventBus()) - - process_canonical_event(canonical_state, older) - process_canonical_event(canonical_state, newer) - _apply_market_direct(direct_state, older) - _apply_market_direct(direct_state, newer) - - assert canonical_state.market == direct_state.market - assert canonical_state.market[instrument].best_bid == 102.0 - assert canonical_state.market[instrument].best_ask == 103.0 - - -def test_market_parity_older_local_timestamp_is_ignored() -> None: - instrument = "BTC-USDC-PERP" - newer = _book_market_event( - instrument=instrument, - ts_ns_local=200, - ts_ns_exch=120, - best_bid=105.0, - best_ask=106.0, - ) - older = _book_market_event( - instrument=instrument, - ts_ns_local=199, - ts_ns_exch=500, - best_bid=90.0, - best_ask=91.0, - ) - - canonical_state = StrategyState(event_bus=NullEventBus()) - direct_state = StrategyState(event_bus=NullEventBus()) - - process_canonical_event(canonical_state, newer) - process_canonical_event(canonical_state, older) - _apply_market_direct(direct_state, newer) - _apply_market_direct(direct_state, older) - - assert canonical_state.market == direct_state.market - assert canonical_state.market[instrument].best_bid == 105.0 - assert canonical_state.market[instrument].best_ask == 106.0 - - -def test_market_parity_equal_local_timestamp_uses_exchange_tiebreak() -> None: - instrument = "BTC-USDC-PERP" - base = _book_market_event( - instrument=instrument, - ts_ns_local=300, - ts_ns_exch=100, - best_bid=110.0, - best_ask=111.0, - ) - higher_exch = _book_market_event( - instrument=instrument, - ts_ns_local=300, - ts_ns_exch=101, - best_bid=112.0, - best_ask=113.0, - ) - lower_exch = _book_market_event( - instrument=instrument, - ts_ns_local=300, - ts_ns_exch=99, - best_bid=80.0, - best_ask=81.0, - ) - - canonical_state = StrategyState(event_bus=NullEventBus()) - direct_state = StrategyState(event_bus=NullEventBus()) - - process_canonical_event(canonical_state, base) - process_canonical_event(canonical_state, higher_exch) - process_canonical_event(canonical_state, lower_exch) - _apply_market_direct(direct_state, base) - _apply_market_direct(direct_state, higher_exch) - _apply_market_direct(direct_state, lower_exch) - - assert canonical_state.market == direct_state.market - assert canonical_state.market[instrument].best_bid == 112.0 - assert canonical_state.market[instrument].best_ask == 113.0 - assert canonical_state.market[instrument].last_ts_ns_exch == 101 - - -def test_fill_parity_single_event_canonical_equals_direct() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - event = _fill_event( - instrument=instrument, - client_order_id=client_order_id, - ts_ns_local=400, - ts_ns_exch=390, - cum_qty=0.25, - ) - - canonical_state = StrategyState(event_bus=NullEventBus()) - direct_state = StrategyState(event_bus=NullEventBus()) - - process_canonical_event(canonical_state, event) - direct_state.apply_fill_event(event) - - assert canonical_state.fills == direct_state.fills - assert canonical_state.fill_cum_qty == direct_state.fill_cum_qty - - -def test_fill_parity_duplicate_and_non_increasing_cumulative_are_idempotent() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - first = _fill_event( - instrument=instrument, - client_order_id=client_order_id, - ts_ns_local=500, - ts_ns_exch=490, - cum_qty=0.25, - ) - duplicate = _fill_event( - instrument=instrument, - client_order_id=client_order_id, - ts_ns_local=501, - ts_ns_exch=491, - cum_qty=0.25, - ) - lower = _fill_event( - instrument=instrument, - client_order_id=client_order_id, - ts_ns_local=502, - ts_ns_exch=492, - cum_qty=0.20, - ) - higher = _fill_event( - instrument=instrument, - client_order_id=client_order_id, - ts_ns_local=503, - ts_ns_exch=493, - cum_qty=0.40, - ) - - canonical_state = StrategyState(event_bus=NullEventBus()) - direct_state = StrategyState(event_bus=NullEventBus()) - - for event in (first, duplicate, lower, higher): - process_canonical_event(canonical_state, event) - direct_state.apply_fill_event(event) - - assert canonical_state.fills == direct_state.fills - assert canonical_state.fill_cum_qty == direct_state.fill_cum_qty - assert len(canonical_state.fills[instrument]) == 2 - assert canonical_state.fill_cum_qty[instrument][client_order_id] == 0.4 - - -@pytest.mark.parametrize( - "artifact", - [ - pytest.param("order_state_event", id="order-state-event"), - pytest.param("risk_decision_event", id="risk-decision-telemetry"), - ], -) -def test_rejected_non_canonical_artifacts_do_not_mutate_state(artifact: str) -> None: - instrument = "BTC-USDC-PERP" - state = StrategyState(event_bus=NullEventBus()) - - seed_market = _book_market_event( - instrument=instrument, - ts_ns_local=700, - ts_ns_exch=690, - best_bid=120.0, - best_ask=121.0, - ) - seed_fill = _fill_event( - instrument=instrument, - client_order_id="order-1", - ts_ns_local=710, - ts_ns_exch=700, - cum_qty=0.25, - ) - process_canonical_event(state, seed_market) - process_canonical_event(state, seed_fill) - - before = _state_subset_snapshot(state) - - if artifact == "order_state_event": - non_canonical = _order_state_event(instrument=instrument, client_order_id="order-compat-1") - else: - non_canonical = RiskDecisionEvent( - ts_ns_local=720, - accepted=1, - queued=0, - rejected=0, - handled=0, - reject_reasons={}, - ) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, non_canonical) - - after = _state_subset_snapshot(state) - assert after == before diff --git a/tests/semantics/models/test_canonical_reducer_authority_guard.py b/tests/semantics/models/test_canonical_reducer_authority_guard.py deleted file mode 100644 index 480154c..0000000 --- a/tests/semantics/models/test_canonical_reducer_authority_guard.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Architectural guard for canonical reducer authority hardening.""" - -from __future__ import annotations - -import ast -from pathlib import Path - -_ALLOWED_CALLER = Path("tradingchassis_core/core/domain/processing.py") -_TARGET_METHODS = frozenset( - { - "update_market", - "apply_fill_event", - "apply_order_submitted_event", - "apply_control_time_event", - } -) - - -def _iter_python_files(root: Path) -> list[Path]: - return sorted(path for path in root.rglob("*.py") if path.is_file()) - - -def _find_target_calls(path: Path) -> list[tuple[int, int, str]]: - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - calls: list[tuple[int, int, str]] = [] - - for node in ast.walk(tree): - if not isinstance(node, ast.Call): - continue - if not isinstance(node.func, ast.Attribute): - continue - method_name = node.func.attr - if method_name not in _TARGET_METHODS: - continue - calls.append((node.lineno, node.col_offset, method_name)) - - return calls - - -def test_direct_reducer_calls_are_limited_to_canonical_processing_boundary() -> None: - repo_root = Path(__file__).resolve().parents[3] - production_root = repo_root / "tradingchassis_core" - - violations: list[str] = [] - - for file_path in _iter_python_files(production_root): - relative_path = file_path.relative_to(repo_root) - calls = _find_target_calls(file_path) - if not calls: - continue - - if relative_path == _ALLOWED_CALLER: - continue - - for lineno, col, method_name in calls: - violations.append(f"{relative_path}:{lineno}:{col} calls {method_name}(...)") - - assert not violations, "Unexpected direct reducer calls outside canonical boundary:\n" + "\n".join( - violations - ) diff --git a/tests/semantics/models/test_core_configuration_contract.py b/tests/semantics/models/test_core_configuration_contract.py deleted file mode 100644 index 251d294..0000000 --- a/tests/semantics/models/test_core_configuration_contract.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Semantics tests for CoreConfiguration identity and stability contract.""" - -from __future__ import annotations - -import pytest - -from tradingchassis_core.core.domain.configuration import CoreConfiguration - - -def test_same_version_and_semantic_payload_produce_same_fingerprint() -> None: - left = CoreConfiguration( - version="v1", - payload={ - "a": 1, - "b": [True, {"x": "y", "z": None}], - }, - ) - right = CoreConfiguration( - version="v1", - payload={ - "b": [True, {"z": None, "x": "y"}], - "a": 1, - }, - ) - - assert left.fingerprint == right.fingerprint - assert left.payload == right.payload - - -def test_different_payload_produces_different_fingerprint() -> None: - left = CoreConfiguration(version="v1", payload={"a": 1}) - right = CoreConfiguration(version="v1", payload={"a": 2}) - - assert left.fingerprint != right.fingerprint - - -def test_different_version_produces_different_fingerprint() -> None: - left = CoreConfiguration(version="v1", payload={"a": 1}) - right = CoreConfiguration(version="v2", payload={"a": 1}) - - assert left.fingerprint != right.fingerprint - - -def test_rejects_unsupported_payload_values() -> None: - with pytest.raises(TypeError, match="Unsupported configuration payload value type"): - CoreConfiguration(version="v1", payload={"unsupported": object()}) - - with pytest.raises(TypeError, match="mapping keys must be strings"): - CoreConfiguration(version="v1", payload={1: "x"}) # type: ignore[dict-item] - - -def test_external_payload_mutation_does_not_change_configuration_identity() -> None: - source = { - "limits": { - "max_orders": 10, - "enabled": True, - }, - "symbols": ["BTC-USDC-PERP", "ETH-USDC-PERP"], - } - - configuration = CoreConfiguration(version="v1", payload=source) - original_fingerprint = configuration.fingerprint - original_payload = configuration.payload - - source["limits"]["max_orders"] = 99 - source["symbols"].append("SOL-USDC-PERP") - source["limits"]["new_key"] = "added" - - assert configuration.fingerprint == original_fingerprint - assert configuration.payload == original_payload diff --git a/tests/semantics/models/test_event_stream_entry_contract.py b/tests/semantics/models/test_event_stream_entry_contract.py deleted file mode 100644 index e9c0923..0000000 --- a/tests/semantics/models/test_event_stream_entry_contract.py +++ /dev/null @@ -1,378 +0,0 @@ -"""Semantics tests for minimal EventStreamEntry contract (Phase 2B.1).""" - -from __future__ import annotations - -import copy -import dataclasses -import inspect - -import pytest - -from tradingchassis_core.core.domain.configuration import CoreConfiguration -from tradingchassis_core.core.domain.event_model import is_canonical_stream_candidate_type -from tradingchassis_core.core.domain.processing import ( - fold_event_stream_entries, - process_event_entry, -) -from tradingchassis_core.core.domain.processing_order import EventStreamEntry, ProcessingPosition -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - ControlTimeEvent, - FillEvent, - MarketEvent, - OrderStateEvent, - OrderSubmittedEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.event_bus import EventBus -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def _book_market_event(*, instrument: str, ts_ns_local: int, ts_ns_exch: int) -> MarketEvent: - return MarketEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - event_type="book", - book={ - "book_type": "snapshot", - "bids": [ - { - "price": {"currency": "USDC", "value": 100.0}, - "quantity": {"unit": "contracts", "value": 2.0}, - } - ], - "asks": [ - { - "price": {"currency": "USDC", "value": 101.0}, - "quantity": {"unit": "contracts", "value": 3.0}, - } - ], - "depth": 1, - }, - trade=None, - ) - - -def _fill_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local: int, - ts_ns_exch: int, - cum_filled_qty: float = 0.25, -) -> FillEvent: - return FillEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=Price(currency="USDC", value=100.5), - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=Quantity(unit="contracts", value=cum_filled_qty), - remaining_qty=Quantity(unit="contracts", value=max(0.0, 1.0 - cum_filled_qty)), - time_in_force="GTC", - liquidity_flag="maker", - fee=None, - ) - - -def _order_state_event(*, instrument: str, client_order_id: str) -> OrderStateEvent: - return OrderStateEvent( - ts_ns_local=300, - ts_ns_exch=290, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type="accepted", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - - -def _order_submitted_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local_dispatch: int, -) -> OrderSubmittedEvent: - return OrderSubmittedEvent( - ts_ns_local_dispatch=ts_ns_local_dispatch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - order_type="limit", - intended_price=Price(currency="USDC", value=100.0), - intended_qty=Quantity(unit="contracts", value=1.0), - time_in_force="GTC", - intent_correlation_id="corr-1", - dispatch_attempt_id="attempt-1", - runtime_correlation={"engine": "backtest", "seq": 1}, - ) - - -def _control_time_event( - *, - ts_ns_local_control: int, - due_ts_ns_local: int | None = None, - realized_ts_ns_local: int | None = None, -) -> ControlTimeEvent: - return ControlTimeEvent( - ts_ns_local_control=ts_ns_local_control, - reason="rate_limit_recheck", - due_ts_ns_local=due_ts_ns_local, - realized_ts_ns_local=realized_ts_ns_local, - obligation_reason="rate_limit", - obligation_due_ts_ns_local=due_ts_ns_local, - runtime_correlation={"engine": "backtest", "seq": 1}, - ) - - -def _state_subset_snapshot(state: StrategyState) -> dict[str, object]: - return { - "market": copy.deepcopy(state.market), - "fills": copy.deepcopy(state.fills), - "fill_cum_qty": copy.deepcopy(state.fill_cum_qty), - } - - -def _market_configuration( - *, - instrument: str = "BTC-USDC-PERP", - tick_size: float = 0.1, - lot_size: float = 0.01, - contract_size: float = 1.0, -) -> CoreConfiguration: - return CoreConfiguration( - version="v1", - payload={ - "market": { - "instruments": { - instrument: { - "tick_size": tick_size, - "lot_size": lot_size, - "contract_size": contract_size, - } - } - } - }, - ) - - -def test_event_stream_entry_requires_processing_position() -> None: - with pytest.raises(TypeError, match="position must be a ProcessingPosition"): - EventStreamEntry(position=object(), event={"x": 1}) - - -def test_event_stream_entry_contract_has_no_configuration_field() -> None: - field_names = {field.name for field in dataclasses.fields(EventStreamEntry)} - assert field_names == {"position", "event"} - assert "configuration" not in field_names - - -def test_configuration_is_call_level_input_not_entry_level_shape() -> None: - process_signature = inspect.signature(process_event_entry) - fold_signature = inspect.signature(fold_event_stream_entries) - - assert "configuration" in process_signature.parameters - assert "configuration" in fold_signature.parameters - assert "configuration" not in {field.name for field in dataclasses.fields(EventStreamEntry)} - - -def test_process_event_entry_processes_market_and_advances_state() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - entry = EventStreamEntry(position=ProcessingPosition(index=0), event=event) - - process_event_entry(state, entry, configuration=_market_configuration()) - - market = state.market["BTC-USDC-PERP"] - assert state._last_processing_position_index == 0 - assert market.best_bid == 100.0 - assert market.best_ask == 101.0 - assert market.last_ts_ns_local == 100 - assert market.last_ts_ns_exch == 90 - - -def test_process_event_entry_processes_fill_and_updates_fill_state() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=200, - ts_ns_exch=180, - ) - entry = EventStreamEntry(position=ProcessingPosition(index=5), event=event) - - process_event_entry(state, entry) - - assert state._last_processing_position_index == 5 - assert len(state.fills["BTC-USDC-PERP"]) == 1 - assert state.fill_cum_qty["BTC-USDC-PERP"]["order-1"] == 0.25 - - -def test_process_event_entry_processes_order_submitted_and_updates_projection() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _order_submitted_event( - instrument="BTC-USDC-PERP", - client_order_id="order-submitted-1", - ts_ns_local_dispatch=250, - ) - entry = EventStreamEntry(position=ProcessingPosition(index=6), event=event) - - process_event_entry(state, entry) - - assert state._last_processing_position_index == 6 - projection = state.canonical_orders[("BTC-USDC-PERP", "order-submitted-1")] - assert projection.state == "submitted" - assert projection.submitted_ts_ns_local == 250 - assert projection.updated_ts_ns_local == 250 - - -def test_process_event_entry_processes_control_time_event_and_advances_cursor() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _control_time_event( - ts_ns_local_control=260, - due_ts_ns_local=300, - ) - entry = EventStreamEntry(position=ProcessingPosition(index=7), event=event) - before = { - "queued_intents": copy.deepcopy(state.queued_intents), - "inflight": copy.deepcopy(state.inflight), - "orders": copy.deepcopy(state.orders), - "canonical_orders": copy.deepcopy(state.canonical_orders), - "fills": copy.deepcopy(state.fills), - "market": copy.deepcopy(state.market), - "account": copy.deepcopy(state.account), - } - - process_event_entry(state, entry) - - after = { - "queued_intents": copy.deepcopy(state.queued_intents), - "inflight": copy.deepcopy(state.inflight), - "orders": copy.deepcopy(state.orders), - "canonical_orders": copy.deepcopy(state.canonical_orders), - "fills": copy.deepcopy(state.fills), - "market": copy.deepcopy(state.market), - "account": copy.deepcopy(state.account), - } - assert state._last_processing_position_index == 7 - assert after == before - - -def test_process_event_entry_rejects_non_canonical_payload() -> None: - state = StrategyState(event_bus=NullEventBus()) - compat_event = _order_state_event( - instrument="BTC-USDC-PERP", - client_order_id="order-compat-1", - ) - entry = EventStreamEntry(position=ProcessingPosition(index=1), event=compat_event) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_event_entry(state, entry) - - -def test_process_event_entry_enforces_processing_position_monotonicity() -> None: - state = StrategyState(event_bus=NullEventBus()) - first = EventStreamEntry( - position=ProcessingPosition(index=10), - event=_book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90), - ) - second = EventStreamEntry( - position=ProcessingPosition(index=11), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=101, - ts_ns_exch=91, - ), - ) - repeated = EventStreamEntry( - position=ProcessingPosition(index=11), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=102, - ts_ns_exch=92, - ), - ) - regressing = EventStreamEntry( - position=ProcessingPosition(index=9), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=103, - ts_ns_exch=93, - ), - ) - - process_event_entry(state, first, configuration=_market_configuration()) - process_event_entry(state, second, configuration=None) - assert state._last_processing_position_index == 11 - before = _state_subset_snapshot(state) - - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - process_event_entry(state, repeated) - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - process_event_entry(state, regressing) - - assert _state_subset_snapshot(state) == before - assert state._last_processing_position_index == 11 - - -def test_process_event_entry_positioned_market_requires_configuration() -> None: - event = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - entry = EventStreamEntry(position=ProcessingPosition(index=0), event=event) - state = StrategyState(event_bus=NullEventBus()) - - with pytest.raises( - ValueError, - match="CoreConfiguration is required for positioned canonical MarketEvent processing", - ): - process_event_entry(state, entry, configuration=None) - - -def test_process_event_entry_positioned_fill_remains_configuration_agnostic() -> None: - event = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=200, - ts_ns_exch=180, - ) - entry = EventStreamEntry(position=ProcessingPosition(index=5), event=event) - state = StrategyState(event_bus=NullEventBus()) - - process_event_entry(state, entry, configuration=None) - - assert state._last_processing_position_index == 5 - assert len(state.fills["BTC-USDC-PERP"]) == 1 - assert state.fill_cum_qty["BTC-USDC-PERP"]["order-1"] == 0.25 - - -def test_process_event_entry_rejects_non_core_configuration() -> None: - event = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - entry = EventStreamEntry(position=ProcessingPosition(index=0), event=event) - state = StrategyState(event_bus=NullEventBus()) - - with pytest.raises(TypeError, match="configuration must be CoreConfiguration or None"): - process_event_entry(state, entry, configuration={"version": "v1"}) - - -def test_event_bus_remains_non_canonical_event_stream_input() -> None: - assert is_canonical_stream_candidate_type(EventBus) is False - - state = StrategyState(event_bus=NullEventBus()) - entry = EventStreamEntry(position=ProcessingPosition(index=0), event=EventBus()) - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_event_entry(state, entry) diff --git a/tests/semantics/models/test_event_taxonomy_boundary.py b/tests/semantics/models/test_event_taxonomy_boundary.py deleted file mode 100644 index ac622f5..0000000 --- a/tests/semantics/models/test_event_taxonomy_boundary.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Semantics tests for the lightweight core event taxonomy boundary.""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.event_model import ( - CANONICAL_EVENT_CATEGORY_NAMES, - COMPATIBILITY_PROJECTION_TYPES, - NON_CANONICAL_CONTROL_HELPER_TYPES, - TELEMETRY_EVENT_TYPES, - CanonicalEventCategory, - canonical_category_for_type, - is_canonical_stream_candidate_type, -) -from tradingchassis_core.core.domain.processing import process_canonical_event -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - ControlTimeEvent, - FillEvent, - MarketEvent, - OrderStateEvent, - OrderSubmittedEvent, -) -from tradingchassis_core.core.events.event_bus import EventBus -from tradingchassis_core.core.events.events import ( - DerivedFillEvent, - DerivedPnLEvent, - ExposureDerivedEvent, - OrderStateTransitionEvent, - RiskDecisionEvent, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation -from tradingchassis_core.core.risk.risk_engine import GateDecision - - -def test_canonical_event_category_names_are_stable() -> None: - """Canonical category names remain docs-aligned and stable.""" - - assert CANONICAL_EVENT_CATEGORY_NAMES == ( - "market", - "intent_related", - "execution", - "control", - ) - - -def test_canonical_stream_candidate_classification_current_slice() -> None: - """Current slice markers keep canonical candidates explicit and minimal.""" - - assert is_canonical_stream_candidate_type(MarketEvent) is True - assert canonical_category_for_type(MarketEvent) == CanonicalEventCategory.MARKET - - assert is_canonical_stream_candidate_type(FillEvent) is True - assert canonical_category_for_type(FillEvent) == CanonicalEventCategory.EXECUTION - - assert is_canonical_stream_candidate_type(OrderSubmittedEvent) is True - assert ( - canonical_category_for_type(OrderSubmittedEvent) - == CanonicalEventCategory.INTENT_RELATED - ) - - assert is_canonical_stream_candidate_type(ControlTimeEvent) is True - assert canonical_category_for_type(ControlTimeEvent) == CanonicalEventCategory.CONTROL - - # Compatibility execution feedback remains non-canonical in this slice. - assert is_canonical_stream_candidate_type(OrderStateEvent) is False - assert OrderStateEvent in COMPATIBILITY_PROJECTION_TYPES - - -def test_event_bus_is_not_canonical_stream_record() -> None: - """EventBus remains a transport abstraction, not a canonical event.""" - - assert is_canonical_stream_candidate_type(EventBus) is False - assert canonical_category_for_type(EventBus) is None - - -def test_gate_decision_is_not_canonical_stream_record() -> None: - """GateDecision remains a compatibility decision contract, not an event.""" - - assert is_canonical_stream_candidate_type(GateDecision) is False - assert canonical_category_for_type(GateDecision) is None - - -def test_control_scheduling_obligation_is_not_an_event() -> None: - """ControlSchedulingObligation is explicitly non-canonical.""" - - assert is_canonical_stream_candidate_type(ControlSchedulingObligation) is False - assert canonical_category_for_type(ControlSchedulingObligation) is None - assert ControlSchedulingObligation in NON_CANONICAL_CONTROL_HELPER_TYPES - - -def test_telemetry_records_are_not_canonical_stream_candidates() -> None: - """Telemetry/observability records remain outside canonical stream markers.""" - - telemetry_types = ( - RiskDecisionEvent, - DerivedPnLEvent, - ExposureDerivedEvent, - OrderStateTransitionEvent, - ) - - for record_type in telemetry_types: - assert record_type in TELEMETRY_EVENT_TYPES - assert is_canonical_stream_candidate_type(record_type) is False - assert canonical_category_for_type(record_type) is None - - # Compatibility projection artifact is also non-canonical. - assert DerivedFillEvent in COMPATIBILITY_PROJECTION_TYPES - assert is_canonical_stream_candidate_type(DerivedFillEvent) is False - assert canonical_category_for_type(DerivedFillEvent) is None - - -def test_process_canonical_event_rejects_order_state_event_guard() -> None: - """Canonical processing boundary rejects compatibility OrderStateEvent records.""" - - state = StrategyState(event_bus=NullEventBus()) - compatibility_record = OrderStateEvent( - ts_ns_local=1, - ts_ns_exch=1, - instrument="BTC-USDC-PERP", - client_order_id="compat-1", - order_type="limit", - state_type="accepted", - side="buy", - intended_price={"currency": "USDC", "value": 100.0}, - filled_price=None, - intended_qty={"unit": "contracts", "value": 1.0}, - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - - try: - process_canonical_event(state, compatibility_record) - except TypeError as exc: - assert "Unsupported non-canonical event type" in str(exc) - else: - raise AssertionError("Expected process_canonical_event to reject OrderStateEvent") - - -def test_process_canonical_event_rejects_derived_fill_event_guard() -> None: - """Canonical processing boundary rejects compatibility DerivedFillEvent records.""" - - state = StrategyState(event_bus=NullEventBus()) - compatibility_record = DerivedFillEvent( - ts_ns_local=1, - instrument="BTC-USDC-PERP", - client_order_id="compat-derived-1", - side="buy", - delta_qty=0.1, - cum_qty=0.1, - price=100.5, - ) - - try: - process_canonical_event(state, compatibility_record) - except TypeError as exc: - assert "Unsupported non-canonical event type" in str(exc) - else: - raise AssertionError("Expected process_canonical_event to reject DerivedFillEvent") - diff --git a/tests/semantics/models/test_fold_event_stream_entries_contract.py b/tests/semantics/models/test_fold_event_stream_entries_contract.py deleted file mode 100644 index 2b3d212..0000000 --- a/tests/semantics/models/test_fold_event_stream_entries_contract.py +++ /dev/null @@ -1,630 +0,0 @@ -"""Semantics tests for minimal deterministic fold/replay contract (Phase 2B.2).""" - -from __future__ import annotations - -import copy - -import pytest - -from tradingchassis_core.core.domain.configuration import CoreConfiguration -from tradingchassis_core.core.domain.processing import fold_event_stream_entries -from tradingchassis_core.core.domain.processing_order import EventStreamEntry, ProcessingPosition -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - ControlTimeEvent, - FillEvent, - MarketEvent, - OrderStateEvent, - OrderSubmittedEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def _book_market_event( - *, - instrument: str, - ts_ns_local: int, - ts_ns_exch: int, - best_bid: float, - best_ask: float, -) -> MarketEvent: - return MarketEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - event_type="book", - book={ - "book_type": "snapshot", - "bids": [ - { - "price": {"currency": "USDC", "value": best_bid}, - "quantity": {"unit": "contracts", "value": 2.0}, - } - ], - "asks": [ - { - "price": {"currency": "USDC", "value": best_ask}, - "quantity": {"unit": "contracts", "value": 3.0}, - } - ], - "depth": 1, - }, - trade=None, - ) - - -def _fill_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local: int, - ts_ns_exch: int, - cum_filled_qty: float, -) -> FillEvent: - return FillEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=Price(currency="USDC", value=100.5), - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=Quantity(unit="contracts", value=cum_filled_qty), - remaining_qty=Quantity(unit="contracts", value=max(0.0, 1.0 - cum_filled_qty)), - time_in_force="GTC", - liquidity_flag="maker", - fee=None, - ) - - -def _order_submitted_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local_dispatch: int, -) -> OrderSubmittedEvent: - return OrderSubmittedEvent( - ts_ns_local_dispatch=ts_ns_local_dispatch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - order_type="limit", - intended_price=Price(currency="USDC", value=100.0), - intended_qty=Quantity(unit="contracts", value=1.0), - time_in_force="GTC", - intent_correlation_id="corr-1", - dispatch_attempt_id="attempt-1", - runtime_correlation={"engine": "backtest", "seq": 1}, - ) - - -def _control_time_event( - *, - ts_ns_local_control: int, - due_ts_ns_local: int | None = None, - realized_ts_ns_local: int | None = None, -) -> ControlTimeEvent: - return ControlTimeEvent( - ts_ns_local_control=ts_ns_local_control, - reason="rate_limit_recheck", - due_ts_ns_local=due_ts_ns_local, - realized_ts_ns_local=realized_ts_ns_local, - obligation_reason="rate_limit", - obligation_due_ts_ns_local=due_ts_ns_local, - runtime_correlation={"engine": "backtest", "seq": 1}, - ) - - -def _order_state_event(*, instrument: str, client_order_id: str) -> OrderStateEvent: - return OrderStateEvent( - ts_ns_local=300, - ts_ns_exch=290, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type="accepted", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - - -def _state_subset_snapshot(state: StrategyState) -> dict[str, object]: - return { - "market": copy.deepcopy(state.market), - "fills": copy.deepcopy(state.fills), - "fill_cum_qty": copy.deepcopy(state.fill_cum_qty), - "processing_position": state._last_processing_position_index, - } - - -def _entry(position: int, event: object) -> EventStreamEntry: - return EventStreamEntry(position=ProcessingPosition(index=position), event=event) - - -def _market_configuration( - *, - instrument: str = "BTC-USDC-PERP", - tick_size: float = 0.1, - lot_size: float = 0.01, - contract_size: float = 1.0, - version: str = "v1", -) -> CoreConfiguration: - return CoreConfiguration( - version=version, - payload={ - "market": { - "instruments": { - instrument: { - "tick_size": tick_size, - "lot_size": lot_size, - "contract_size": contract_size, - } - } - } - }, - ) - - -def test_fold_same_entries_same_configuration_produces_equivalent_final_state() -> None: - entries = [ - _entry( - 0, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ), - _entry( - 1, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=201, - ts_ns_exch=191, - cum_filled_qty=0.25, - ), - ), - ] - configuration = _market_configuration() - - left = StrategyState(event_bus=NullEventBus()) - right = StrategyState(event_bus=NullEventBus()) - - fold_event_stream_entries(left, entries, configuration=configuration) - fold_event_stream_entries(right, entries, configuration=configuration) - - assert _state_subset_snapshot(left) == _state_subset_snapshot(right) - - -def test_fold_uses_single_explicit_configuration_input_with_stable_identity() -> None: - """Phase 2B guardrail: one fold call has one explicit CoreConfiguration input.""" - entries = [ - _entry( - 0, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ), - _entry( - 1, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=201, - ts_ns_exch=191, - cum_filled_qty=0.25, - ), - ), - ] - cfg_v1_left = _market_configuration(version="v1") - cfg_v1_right = _market_configuration(version="v1") - - left = StrategyState(event_bus=NullEventBus()) - right = StrategyState(event_bus=NullEventBus()) - - fold_event_stream_entries(left, entries, configuration=cfg_v1_left) - fold_event_stream_entries(right, entries, configuration=cfg_v1_right) - - assert cfg_v1_left.fingerprint == cfg_v1_right.fingerprint - assert _state_subset_snapshot(left) == _state_subset_snapshot(right) - - -def test_fold_same_prefix_produces_equivalent_prefix_state() -> None: - entries = [ - _entry( - 0, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ), - _entry( - 1, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=100, - ts_ns_exch=95, - best_bid=120.0, - best_ask=121.0, - ), - ), - _entry( - 2, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=202, - ts_ns_exch=192, - cum_filled_qty=0.25, - ), - ), - ] - configuration = _market_configuration() - - left = StrategyState(event_bus=NullEventBus()) - right = StrategyState(event_bus=NullEventBus()) - - fold_event_stream_entries(left, entries[:2], configuration=configuration) - fold_event_stream_entries(right, entries[:2], configuration=configuration) - - assert _state_subset_snapshot(left) == _state_subset_snapshot(right) - - -def test_fold_repeated_or_regressing_processing_position_raises_deterministically() -> None: - repeated_state = StrategyState(event_bus=NullEventBus()) - repeated_entries = [ - _entry( - 10, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ), - _entry( - 10, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=201, - ts_ns_exch=191, - cum_filled_qty=0.25, - ), - ), - ] - - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - fold_event_stream_entries(repeated_state, repeated_entries, configuration=_market_configuration()) - - regressing_state = StrategyState(event_bus=NullEventBus()) - regressing_entries = [ - _entry( - 11, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ), - _entry( - 9, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=202, - ts_ns_exch=192, - cum_filled_qty=0.50, - ), - ), - ] - - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - fold_event_stream_entries(regressing_state, regressing_entries, configuration=_market_configuration()) - - -def test_fold_positioned_market_ordering_follows_processing_position_not_event_time() -> None: - state = StrategyState(event_bus=NullEventBus()) - entries = [ - _entry( - 1, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ), - _entry( - 2, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=100, - ts_ns_exch=95, - best_bid=120.0, - best_ask=121.0, - ), - ), - ] - - fold_event_stream_entries(state, entries, configuration=_market_configuration()) - - market = state.market["BTC-USDC-PERP"] - assert market.best_bid == 120.0 - assert market.best_ask == 121.0 - assert market.last_ts_ns_local == 100 - assert market.last_ts_ns_exch == 95 - assert state._last_processing_position_index == 2 - - -def test_fold_interleaved_market_submitted_control_uses_single_global_cursor() -> None: - state = StrategyState(event_bus=NullEventBus()) - entries = [ - _entry( - 0, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ), - _entry( - 1, - _order_submitted_event( - instrument="BTC-USDC-PERP", - client_order_id="order-submitted-1", - ts_ns_local_dispatch=205, - ), - ), - _entry( - 2, - _control_time_event( - ts_ns_local_control=206, - due_ts_ns_local=210, - ), - ), - ] - - fold_event_stream_entries(state, entries, configuration=_market_configuration()) - - assert state._last_processing_position_index == 2 - projection = state.canonical_orders[("BTC-USDC-PERP", "order-submitted-1")] - assert projection.state == "submitted" - - -def test_fold_fill_event_cumulative_idempotence_remains_unchanged() -> None: - state = StrategyState(event_bus=NullEventBus()) - entries = [ - _entry( - 20, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=400, - ts_ns_exch=390, - cum_filled_qty=0.25, - ), - ), - _entry( - 21, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=401, - ts_ns_exch=391, - cum_filled_qty=0.25, - ), - ), - _entry( - 22, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=402, - ts_ns_exch=392, - cum_filled_qty=0.20, - ), - ), - ] - - fold_event_stream_entries(state, entries) - - assert len(state.fills["BTC-USDC-PERP"]) == 1 - assert state.fill_cum_qty["BTC-USDC-PERP"]["order-1"] == 0.25 - assert state._last_processing_position_index == 22 - - -def test_fold_rejects_non_canonical_entry_payload_via_existing_boundary() -> None: - state = StrategyState(event_bus=NullEventBus()) - entries = [ - _entry( - 1, - _order_state_event( - instrument="BTC-USDC-PERP", - client_order_id="order-compat-1", - ), - ) - ] - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - fold_event_stream_entries(state, entries) - - -def test_fold_returns_same_state_object_for_ergonomics() -> None: - state = StrategyState(event_bus=NullEventBus()) - entries = [ - _entry( - 0, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ) - ] - - configuration = _market_configuration() - returned = fold_event_stream_entries(state, entries, configuration=configuration) - - assert returned is state - - -def test_fold_rejects_non_core_configuration() -> None: - state = StrategyState(event_bus=NullEventBus()) - entries = [ - _entry( - 0, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ) - ] - - with pytest.raises(TypeError, match="configuration must be CoreConfiguration or None"): - fold_event_stream_entries(state, entries, configuration={"version": "v1"}) - - -def test_fold_different_market_configuration_values_produce_different_market_metadata() -> None: - entries = [ - _entry( - 0, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ), - _entry( - 1, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=201, - ts_ns_exch=191, - cum_filled_qty=0.25, - ), - ), - ] - configuration_a = _market_configuration( - tick_size=0.1, - lot_size=0.01, - contract_size=1.0, - version="v1", - ) - configuration_b = _market_configuration( - tick_size=0.5, - lot_size=0.05, - contract_size=2.0, - version="v2", - ) - - left = StrategyState(event_bus=NullEventBus()) - right = StrategyState(event_bus=NullEventBus()) - - fold_event_stream_entries(left, entries, configuration=configuration_a) - fold_event_stream_entries(right, entries, configuration=configuration_b) - - assert configuration_a.fingerprint != configuration_b.fingerprint - assert _state_subset_snapshot(left) != _state_subset_snapshot(right) - left_market = left.market["BTC-USDC-PERP"] - right_market = right.market["BTC-USDC-PERP"] - assert (left_market.tick_size, left_market.lot_size, left_market.contract_size) == ( - 0.1, - 0.01, - 1.0, - ) - assert (right_market.tick_size, right_market.lot_size, right_market.contract_size) == ( - 0.5, - 0.05, - 2.0, - ) - - -def test_fold_configuration_identity_stays_stable_after_source_payload_mutation() -> None: - """Transitional guardrail: configuration identity remains stable during fold.""" - entries = [ - _entry( - 0, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ), - _entry( - 1, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=201, - ts_ns_exch=191, - cum_filled_qty=0.25, - ), - ), - ] - source_payload = { - "market": { - "instruments": { - "BTC-USDC-PERP": { - "tick_size": 0.1, - "lot_size": 0.01, - "contract_size": 1.0, - } - } - } - } - configuration = CoreConfiguration(version="v1", payload=source_payload) - fingerprint_before = configuration.fingerprint - payload_before = configuration.payload - - source_payload["market"]["instruments"]["BTC-USDC-PERP"]["tick_size"] = 0.5 - source_payload["market"]["instruments"]["BTC-USDC-PERP"]["lot_size"] = 0.5 - source_payload["market"]["instruments"]["BTC-USDC-PERP"]["contract_size"] = 5.0 - - left = StrategyState(event_bus=NullEventBus()) - right = StrategyState(event_bus=NullEventBus()) - - fold_event_stream_entries(left, entries, configuration=configuration) - fold_event_stream_entries(right, entries, configuration=configuration) - - source_payload["market"]["instruments"]["BTC-USDC-PERP"]["tick_size"] = 99.0 - - assert configuration.fingerprint == fingerprint_before - assert configuration.payload == payload_before - assert _state_subset_snapshot(left) == _state_subset_snapshot(right) diff --git a/tests/semantics/models/test_import_compatibility_shim.py b/tests/semantics/models/test_import_compatibility_shim.py deleted file mode 100644 index 320e8bb..0000000 --- a/tests/semantics/models/test_import_compatibility_shim.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -import warnings - - -def test_nested_modules_share_identity_across_import_sites() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - import tradingchassis_core.core.domain.processing as old_processing - import tradingchassis_core.core.domain.types as old_types - - import tradingchassis_core.core.domain.processing as new_processing - import tradingchassis_core.core.domain.types as new_types - - assert old_types is new_types - assert old_processing is new_processing - - -def test_symbols_share_identity_across_import_sites() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - from tradingchassis_core.core.domain.configuration import ( - CoreConfiguration as OldCoreConfiguration, - ) - from tradingchassis_core.core.domain.types import ( - ControlTimeEvent as OldControlTimeEvent, - ) - from tradingchassis_core.core.domain.types import ( - MarketEvent as OldMarketEvent, - ) - from tradingchassis_core.core.domain.types import ( - OrderSubmittedEvent as OldOrderSubmittedEvent, - ) - - from tradingchassis_core.core.domain.configuration import ( - CoreConfiguration as NewCoreConfiguration, - ) - from tradingchassis_core.core.domain.types import ( - ControlTimeEvent as NewControlTimeEvent, - ) - from tradingchassis_core.core.domain.types import ( - MarketEvent as NewMarketEvent, - ) - from tradingchassis_core.core.domain.types import ( - OrderSubmittedEvent as NewOrderSubmittedEvent, - ) - - assert OldMarketEvent is NewMarketEvent - assert OldOrderSubmittedEvent is NewOrderSubmittedEvent - assert OldControlTimeEvent is NewControlTimeEvent - assert OldCoreConfiguration is NewCoreConfiguration diff --git a/tests/semantics/models/test_market_configuration_positioned_contract.py b/tests/semantics/models/test_market_configuration_positioned_contract.py deleted file mode 100644 index 8e1b6ac..0000000 --- a/tests/semantics/models/test_market_configuration_positioned_contract.py +++ /dev/null @@ -1,398 +0,0 @@ -"""Semantics contract matrix for positioned MarketEvent configuration consumption. - -Phase 3A.3 treats this module as the primary guardrail reference for the -CoreConfiguration -> positioned canonical MarketEvent contract. -""" - -from __future__ import annotations - -import ast -import copy -from pathlib import Path - -import pytest - -from tradingchassis_core.core.domain.configuration import CoreConfiguration -from tradingchassis_core.core.domain.processing import ( - fold_event_stream_entries, - process_canonical_event, - process_event_entry, -) -from tradingchassis_core.core.domain.processing_order import EventStreamEntry, ProcessingPosition -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - FillEvent, - MarketEvent, - OrderStateEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def _book_market_event( - *, - instrument: str = "BTC-USDC-PERP", - ts_ns_local: int = 100, - ts_ns_exch: int = 90, - best_bid: float = 100.0, - best_ask: float = 101.0, -) -> MarketEvent: - return MarketEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - event_type="book", - book={ - "book_type": "snapshot", - "bids": [ - { - "price": {"currency": "USDC", "value": best_bid}, - "quantity": {"unit": "contracts", "value": 2.0}, - } - ], - "asks": [ - { - "price": {"currency": "USDC", "value": best_ask}, - "quantity": {"unit": "contracts", "value": 3.0}, - } - ], - "depth": 1, - }, - trade=None, - ) - - -def _fill_event(*, instrument: str = "BTC-USDC-PERP", cum_qty: float = 0.25) -> FillEvent: - return FillEvent( - ts_ns_local=200, - ts_ns_exch=190, - instrument=instrument, - client_order_id="order-1", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=Price(currency="USDC", value=100.5), - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=Quantity(unit="contracts", value=cum_qty), - remaining_qty=Quantity(unit="contracts", value=max(0.0, 1.0 - cum_qty)), - time_in_force="GTC", - liquidity_flag="maker", - fee=None, - ) - - -def _order_state_event() -> OrderStateEvent: - return OrderStateEvent( - ts_ns_local=300, - ts_ns_exch=290, - instrument="BTC-USDC-PERP", - client_order_id="order-compat-1", - order_type="limit", - state_type="accepted", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - - -def _market_configuration(*, instrument: str = "BTC-USDC-PERP", tick: object = 0.1, lot: object = 0.01, contract: object = 1.0) -> CoreConfiguration: - return CoreConfiguration( - version="v1", - payload={ - "market": { - "instruments": { - instrument: { - "tick_size": tick, - "lot_size": lot, - "contract_size": contract, - } - } - } - }, - ) - - -def _entry(position: int, event: object) -> EventStreamEntry: - return EventStreamEntry(position=ProcessingPosition(index=position), event=event) - - -def _market_and_cursor_snapshot(state: StrategyState) -> tuple[dict[str, object], int | None]: - return copy.deepcopy(state.market), state._last_processing_position_index - - -def test_fold_positioned_market_requires_configuration_when_none() -> None: - state = StrategyState(event_bus=NullEventBus()) - entries = [_entry(0, _book_market_event())] - - with pytest.raises( - ValueError, - match="CoreConfiguration is required for positioned canonical MarketEvent processing", - ): - fold_event_stream_entries(state, entries, configuration=None) - - -def test_process_event_entry_missing_market_raises() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = _entry(0, _book_market_event()) - cfg = CoreConfiguration(version="v1", payload={"not_market": {}}) - - with pytest.raises(ValueError, match="payload.market"): - process_event_entry(state, entry, configuration=cfg) - - -def test_process_event_entry_missing_instruments_raises() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = _entry(0, _book_market_event()) - cfg = CoreConfiguration(version="v1", payload={"market": {"not_instruments": {}}}) - - with pytest.raises(ValueError, match="payload.market.instruments"): - process_event_entry(state, entry, configuration=cfg) - - -def test_process_event_entry_missing_instrument_entry_raises() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = _entry(0, _book_market_event(instrument="BTC-USDC-PERP")) - cfg = _market_configuration(instrument="ETH-USDC-PERP") - - with pytest.raises(ValueError, match="payload.market.instruments.BTC-USDC-PERP"): - process_event_entry(state, entry, configuration=cfg) - - -@pytest.mark.parametrize( - ("payload", "expected"), - [ - ({"lot_size": 0.01, "contract_size": 1.0}, "tick_size"), - ({"tick_size": 0.1, "contract_size": 1.0}, "lot_size"), - ({"tick_size": 0.1, "lot_size": 0.01}, "contract_size"), - ], -) -def test_process_event_entry_missing_required_field_raises( - payload: dict[str, object], - expected: str, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = _entry(0, _book_market_event()) - cfg = CoreConfiguration( - version="v1", - payload={"market": {"instruments": {"BTC-USDC-PERP": payload}}}, - ) - - with pytest.raises(ValueError, match=expected): - process_event_entry(state, entry, configuration=cfg) - - -@pytest.mark.parametrize("field_name", ["tick_size", "lot_size", "contract_size"]) -def test_process_event_entry_none_field_raises(field_name: str) -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = _entry(0, _book_market_event()) - payload = {"tick_size": 0.1, "lot_size": 0.01, "contract_size": 1.0} - payload[field_name] = None - cfg = CoreConfiguration( - version="v1", - payload={"market": {"instruments": {"BTC-USDC-PERP": payload}}}, - ) - - with pytest.raises(ValueError, match=field_name): - process_event_entry(state, entry, configuration=cfg) - - -@pytest.mark.parametrize("field_name", ["tick_size", "lot_size", "contract_size"]) -def test_process_event_entry_invalid_type_field_raises(field_name: str) -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = _entry(0, _book_market_event()) - payload = {"tick_size": 0.1, "lot_size": 0.01, "contract_size": 1.0} - payload[field_name] = "invalid" - cfg = CoreConfiguration( - version="v1", - payload={"market": {"instruments": {"BTC-USDC-PERP": payload}}}, - ) - - with pytest.raises(TypeError, match="must be numeric"): - process_event_entry(state, entry, configuration=cfg) - - -@pytest.mark.parametrize("field_name", ["tick_size", "lot_size", "contract_size"]) -def test_process_event_entry_bool_field_raises(field_name: str) -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = _entry(0, _book_market_event()) - payload = {"tick_size": 0.1, "lot_size": 0.01, "contract_size": 1.0} - payload[field_name] = True - cfg = CoreConfiguration( - version="v1", - payload={"market": {"instruments": {"BTC-USDC-PERP": payload}}}, - ) - - with pytest.raises(TypeError, match="must be numeric"): - process_event_entry(state, entry, configuration=cfg) - - -@pytest.mark.parametrize("field_name", ["tick_size", "lot_size", "contract_size"]) -@pytest.mark.parametrize("value", [0.0, -1.0]) -def test_process_event_entry_non_positive_field_raises(field_name: str, value: float) -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = _entry(0, _book_market_event()) - payload = {"tick_size": 0.1, "lot_size": 0.01, "contract_size": 1.0} - payload[field_name] = value - cfg = CoreConfiguration( - version="v1", - payload={"market": {"instruments": {"BTC-USDC-PERP": payload}}}, - ) - - with pytest.raises(ValueError, match="must be > 0"): - process_event_entry(state, entry, configuration=cfg) - - -@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")]) -def test_non_finite_market_metadata_rejected_by_core_configuration_validation(bad: float) -> None: - with pytest.raises(ValueError, match="non-finite float"): - _market_configuration(tick=bad) - - -def test_positioned_market_failure_does_not_mutate_market_or_cursor() -> None: - state = StrategyState(event_bus=NullEventBus()) - seed_entry = _entry(1, _book_market_event(ts_ns_local=100, ts_ns_exch=90)) - bad_entry = _entry(2, _book_market_event(ts_ns_local=101, ts_ns_exch=91)) - good_cfg = _market_configuration() - bad_cfg = CoreConfiguration( - version="v1", - payload={"market": {"instruments": {"BTC-USDC-PERP": {"tick_size": 0.1}}}}, - ) - - process_event_entry(state, seed_entry, configuration=good_cfg) - before_market, before_cursor = _market_and_cursor_snapshot(state) - - with pytest.raises(ValueError): - process_event_entry(state, bad_entry, configuration=bad_cfg) - - after_market, after_cursor = _market_and_cursor_snapshot(state) - assert after_market == before_market - assert after_cursor == before_cursor - - -def test_same_positioned_market_stream_semantically_equivalent_configuration_equivalent_state() -> None: - left = StrategyState(event_bus=NullEventBus()) - right = StrategyState(event_bus=NullEventBus()) - entries = [ - _entry(0, _book_market_event(ts_ns_local=100, ts_ns_exch=90)), - _entry(1, _book_market_event(ts_ns_local=101, ts_ns_exch=91, best_bid=102.0, best_ask=103.0)), - ] - cfg_left = _market_configuration(tick=0.1, lot=0.01, contract=1) - cfg_right = _market_configuration(tick=0.1, lot=0.01, contract=1.0) - - fold_event_stream_entries(left, entries, configuration=cfg_left) - fold_event_stream_entries(right, entries, configuration=cfg_right) - - assert left.market == right.market - - -def test_direct_update_market_compatibility_path_unchanged() -> None: - state = StrategyState(event_bus=NullEventBus()) - - state.update_market( - instrument="BTC-USDC-PERP", - best_bid=100.0, - best_ask=101.0, - best_bid_qty=2.0, - best_ask_qty=3.0, - tick_size=0.0, - lot_size=0.0, - contract_size=1.0, - ts_ns_local=200, - ts_ns_exch=190, - ) - state.update_market( - instrument="BTC-USDC-PERP", - best_bid=120.0, - best_ask=121.0, - best_bid_qty=2.0, - best_ask_qty=3.0, - tick_size=0.0, - lot_size=0.0, - contract_size=1.0, - ts_ns_local=100, - ts_ns_exch=95, - ) - - market = state.market["BTC-USDC-PERP"] - assert market.best_bid == 100.0 - assert market.best_ask == 101.0 - assert market.last_ts_ns_local == 200 - assert market.last_ts_ns_exch == 190 - - -def test_unpositioned_market_compatibility_path_unchanged() -> None: - state = StrategyState(event_bus=NullEventBus()) - first = _book_market_event(ts_ns_local=200, ts_ns_exch=190, best_bid=100.0, best_ask=101.0) - second = _book_market_event(ts_ns_local=100, ts_ns_exch=95, best_bid=120.0, best_ask=121.0) - cfg = _market_configuration(tick=0.5, lot=0.5, contract=5.0) - - process_canonical_event(state, first, position=None, configuration=cfg) - process_canonical_event(state, second, position=None, configuration=cfg) - - market = state.market["BTC-USDC-PERP"] - assert market.best_bid == 100.0 - assert market.best_ask == 101.0 - assert market.tick_size == 0.0 - assert market.lot_size == 0.0 - assert market.contract_size == 1.0 - - -def test_fill_event_behavior_remains_unchanged() -> None: - state = StrategyState(event_bus=NullEventBus()) - first = _entry(10, _fill_event(cum_qty=0.25)) - duplicate = _entry(11, _fill_event(cum_qty=0.25)) - regressing = _entry(12, _fill_event(cum_qty=0.20)) - - process_event_entry(state, first, configuration=None) - fills_before = copy.deepcopy(state.fills) - cum_before = copy.deepcopy(state.fill_cum_qty) - process_event_entry(state, duplicate, configuration=None) - process_event_entry(state, regressing, configuration=None) - - assert state.fills == fills_before - assert state.fill_cum_qty == cum_before - assert state._last_processing_position_index == 12 - - -def test_order_state_event_remains_compatibility_only() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = _entry(0, _order_state_event()) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_event_entry(state, entry, configuration=_market_configuration()) - - -def test_positioned_market_contract_does_not_import_runtime_configuration_mapping() -> None: - """Guardrail: canonical market reducer contract stays CoreConfiguration-only.""" - repo_root = Path(__file__).resolve().parents[3] - processing_path = repo_root / "tradingchassis_core/core/domain/processing.py" - tree = ast.parse(processing_path.read_text(encoding="utf-8"), filename=str(processing_path)) - - forbidden_modules = ( - "core_runtime", - "trading_runtime", - "hft_engine_config", - "live_engine_config", - ) - forbidden_symbols = { - "HftEngineConfig", - "LiveEngineConfig", - "RiskConfig", - } - - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - assert not alias.name.startswith(forbidden_modules) - assert alias.name not in forbidden_symbols - if isinstance(node, ast.ImportFrom): - if node.module is not None: - assert not node.module.startswith(forbidden_modules) - for alias in node.names: - assert alias.name not in forbidden_symbols diff --git a/tests/semantics/models/test_market_reducer_positioned_target.py b/tests/semantics/models/test_market_reducer_positioned_target.py deleted file mode 100644 index 94d2758..0000000 --- a/tests/semantics/models/test_market_reducer_positioned_target.py +++ /dev/null @@ -1,331 +0,0 @@ -"""Target tests for positioned MarketEvent reducer-ordering migration (Phase 2 / Slice 2A.3A). - -This file intentionally includes docs-aligned target tests that are expected-red -until the production market reducer migrates from timestamp-compatibility -ordering to ProcessingPosition-driven causal ordering for positioned canonical -MarketEvents. -""" - -from __future__ import annotations - -import copy - -import pytest - -from tradingchassis_core.core.domain.configuration import CoreConfiguration -from tradingchassis_core.core.domain.processing import process_canonical_event -from tradingchassis_core.core.domain.processing_order import ProcessingPosition -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - FillEvent, - MarketEvent, - OrderStateEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def _market_configuration( - *, - instrument: str = "BTC-USDC-PERP", - tick_size: float = 0.1, - lot_size: float = 0.01, - contract_size: float = 1.0, -) -> CoreConfiguration: - return CoreConfiguration( - version="v1", - payload={ - "market": { - "instruments": { - instrument: { - "tick_size": tick_size, - "lot_size": lot_size, - "contract_size": contract_size, - } - } - } - }, - ) - - -def _book_market_event( - *, - instrument: str, - ts_ns_local: int, - ts_ns_exch: int, - best_bid: float, - best_ask: float, - best_bid_qty: float = 2.0, - best_ask_qty: float = 3.0, -) -> MarketEvent: - return MarketEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - event_type="book", - book={ - "book_type": "snapshot", - "bids": [ - { - "price": {"currency": "USDC", "value": best_bid}, - "quantity": {"unit": "contracts", "value": best_bid_qty}, - } - ], - "asks": [ - { - "price": {"currency": "USDC", "value": best_ask}, - "quantity": {"unit": "contracts", "value": best_ask_qty}, - } - ], - "depth": 1, - }, - trade=None, - ) - - -def _fill_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local: int, - ts_ns_exch: int, - cum_qty: float, -) -> FillEvent: - return FillEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=Price(currency="USDC", value=100.5), - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=Quantity(unit="contracts", value=cum_qty), - remaining_qty=Quantity(unit="contracts", value=max(0.0, 1.0 - cum_qty)), - time_in_force="GTC", - liquidity_flag="maker", - fee=None, - ) - - -def _order_state_event(*, instrument: str, client_order_id: str) -> OrderStateEvent: - return OrderStateEvent( - ts_ns_local=300, - ts_ns_exch=290, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type="accepted", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - - -def test_target_positioned_market_lower_local_timestamp_still_advances_state() -> None: - """TARGET (expected-red pre-migration): positioned MarketEvent follows ProcessingPosition causality.""" - instrument = "BTC-USDC-PERP" - state = StrategyState(event_bus=NullEventBus()) - - first = _book_market_event( - instrument=instrument, - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ) - older_local_second = _book_market_event( - instrument=instrument, - ts_ns_local=100, - ts_ns_exch=95, - best_bid=120.0, - best_ask=121.0, - ) - - configuration = _market_configuration(instrument=instrument) - process_canonical_event( - state, - first, - position=ProcessingPosition(index=1), - configuration=configuration, - ) - process_canonical_event( - state, - older_local_second, - position=ProcessingPosition(index=2), - configuration=configuration, - ) - - market = state.market[instrument] - assert state._last_processing_position_index == 2 - # Docs-aligned target: positioned acceptance implies reducer advancement. - assert market.best_bid == 120.0 - assert market.best_ask == 121.0 - assert market.last_ts_ns_local == 100 - assert market.last_ts_ns_exch == 95 - - -def test_target_positioned_market_lower_exchange_timestamp_still_advances_state() -> None: - """TARGET (expected-red pre-migration): exchange-time tie-break must not gate positioned events.""" - instrument = "BTC-USDC-PERP" - state = StrategyState(event_bus=NullEventBus()) - - base = _book_market_event( - instrument=instrument, - ts_ns_local=300, - ts_ns_exch=200, - best_bid=100.0, - best_ask=101.0, - ) - lower_exchange_second = _book_market_event( - instrument=instrument, - ts_ns_local=300, - ts_ns_exch=199, - best_bid=80.0, - best_ask=81.0, - ) - - configuration = _market_configuration(instrument=instrument) - process_canonical_event( - state, - base, - position=ProcessingPosition(index=10), - configuration=configuration, - ) - process_canonical_event( - state, - lower_exchange_second, - position=ProcessingPosition(index=11), - configuration=configuration, - ) - - market = state.market[instrument] - assert state._last_processing_position_index == 11 - # Docs-aligned target: ProcessingPosition is causal; event-time fields are metadata. - assert market.best_bid == 80.0 - assert market.best_ask == 81.0 - assert market.last_ts_ns_local == 300 - assert market.last_ts_ns_exch == 199 - - -def test_migration_guard_unpositioned_canonical_market_keeps_timestamp_compatibility_behavior() -> None: - instrument = "BTC-USDC-PERP" - state = StrategyState(event_bus=NullEventBus()) - - first = _book_market_event( - instrument=instrument, - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ) - second = _book_market_event( - instrument=instrument, - ts_ns_local=100, - ts_ns_exch=95, - best_bid=120.0, - best_ask=121.0, - ) - - process_canonical_event(state, first, position=None) - process_canonical_event(state, second, position=None) - - market = state.market[instrument] - assert market.best_bid == 100.0 - assert market.best_ask == 101.0 - assert market.last_ts_ns_local == 200 - assert market.last_ts_ns_exch == 190 - - -def test_migration_guard_direct_update_market_keeps_timestamp_compatibility_behavior() -> None: - instrument = "BTC-USDC-PERP" - state = StrategyState(event_bus=NullEventBus()) - - state.update_market( - instrument=instrument, - best_bid=100.0, - best_ask=101.0, - best_bid_qty=2.0, - best_ask_qty=3.0, - tick_size=0.0, - lot_size=0.0, - contract_size=1.0, - ts_ns_local=200, - ts_ns_exch=190, - ) - state.update_market( - instrument=instrument, - best_bid=120.0, - best_ask=121.0, - best_bid_qty=2.0, - best_ask_qty=3.0, - tick_size=0.0, - lot_size=0.0, - contract_size=1.0, - ts_ns_local=100, - ts_ns_exch=95, - ) - - market = state.market[instrument] - assert market.best_bid == 100.0 - assert market.best_ask == 101.0 - assert market.last_ts_ns_local == 200 - assert market.last_ts_ns_exch == 190 - - -def test_migration_guard_fill_event_cumulative_idempotence_remains_unchanged() -> None: - state = StrategyState(event_bus=NullEventBus()) - instrument = "BTC-USDC-PERP" - order_id = "order-1" - - first = _fill_event( - instrument=instrument, - client_order_id=order_id, - ts_ns_local=400, - ts_ns_exch=390, - cum_qty=0.25, - ) - duplicate = _fill_event( - instrument=instrument, - client_order_id=order_id, - ts_ns_local=401, - ts_ns_exch=391, - cum_qty=0.25, - ) - regressing = _fill_event( - instrument=instrument, - client_order_id=order_id, - ts_ns_local=402, - ts_ns_exch=392, - cum_qty=0.20, - ) - - process_canonical_event(state, first, position=ProcessingPosition(index=20)) - fills_before = copy.deepcopy(state.fills) - fill_cum_before = copy.deepcopy(state.fill_cum_qty) - - process_canonical_event(state, duplicate, position=ProcessingPosition(index=21)) - process_canonical_event(state, regressing, position=ProcessingPosition(index=22)) - - assert state.fills == fills_before - assert state.fill_cum_qty == fill_cum_before - assert len(state.fills[instrument]) == 1 - assert state.fill_cum_qty[instrument][order_id] == 0.25 - - -def test_migration_guard_order_state_event_remains_rejected_by_canonical_boundary() -> None: - state = StrategyState(event_bus=NullEventBus()) - compat_event = _order_state_event( - instrument="BTC-USDC-PERP", - client_order_id="order-compat-1", - ) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, compat_event, position=ProcessingPosition(index=1)) diff --git a/tests/semantics/models/test_models_against_schemas.py b/tests/semantics/models/test_models_against_schemas.py deleted file mode 100644 index 6a44694..0000000 --- a/tests/semantics/models/test_models_against_schemas.py +++ /dev/null @@ -1,580 +0,0 @@ -"""Schema conformance tests for core Pydantic models. - -This test suite validates that core Pydantic models both accept valid inputs -and reject invalid ones in strict alignment with their corresponding JSON -Schemas. The tests are intentionally verbose and repetitive to ensure full -coverage and explicit failure modes. -""" - -# pylint: disable=line-too-long,missing-function-docstring -# pylint: disable=redefined-outer-name,global-statement -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any, TypeVar - -import pytest -from jsonschema import ValidationError as JsonSchemaValidationError -from jsonschema import validate as jsonschema_validate -from pydantic import TypeAdapter -from pydantic import ValidationError as PydanticValidationError -from referencing import Registry, Resource -from referencing.jsonschema import DRAFT202012 - -from tradingchassis_core.core.domain.types import ( - FillEvent, - MarketEvent, - OrderIntent, - OrderStateEvent, - RiskConstraints, -) - -SCHEMA_REGISTRY = Registry() - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def load_schema(name: str) -> dict: - """ - Load JSON schema from project root. - """ - global SCHEMA_REGISTRY - - root = Path(__file__).parent.parent.parent.parent - name = "tradingchassis_core/core/schemas/" + name - schema_path = root / name - - with schema_path.open("r", encoding="utf-8") as f: - schema = json.load(f) - - schema_id = schema.get("$id") - if isinstance(schema_id, str) and schema_id: - resource = Resource.from_contents(schema, default_specification=DRAFT202012) - SCHEMA_REGISTRY = SCHEMA_REGISTRY.with_resource(schema_id, resource) - - return schema - - -def dump_for_jsonschema(model: Any) -> dict: - """ - Dump a Pydantic model to a JSON-compatible dict for schema validation. - Excludes None values so optional fields are omitted instead of null. - """ - # All validated objects in this test suite are BaseModel instances. - return model.model_dump(mode="json", exclude_none=True) - - -T = TypeVar("T") - - -def pydantic_validate(model_type: Any, data: dict[str, Any]) -> Any: - """ - Validate input using Pydantic for both: - - BaseModel subclasses (e.g., MarketEvent) - - Discriminated unions (e.g., OrderIntent is an Annotated Union) - """ - adapter = TypeAdapter(model_type) - return adapter.validate_python(data) - - -def assert_pydantic_then_schema_ok(model_type: Any, data: dict[str, Any], schema: dict[str, Any]) -> dict: - """ - Validate with Pydantic first, then validate the dumped instance with JSON Schema. - Returns the dumped instance. - """ - obj = pydantic_validate(model_type, data) - instance = dump_for_jsonschema(obj) - jsonschema_validate(instance=instance, schema=schema, registry=SCHEMA_REGISTRY) - return instance - - -def assert_schema_invalid_but_pydantic_rejects(model_type: Any, data: dict[str, Any], schema: dict[str, Any]): - """ - Ensures Pydantic is at least as strict as the JSON Schema for the given input. - If schema rejects, Pydantic must reject too (otherwise model is too lax). - """ - with pytest.raises(JsonSchemaValidationError): - jsonschema_validate(instance=data, schema=schema, registry=SCHEMA_REGISTRY) - - with pytest.raises(PydanticValidationError): - pydantic_validate(model_type, data) - - -def mk_price(value: float, currency: str = "USD") -> dict[str, Any]: - return {"currency": currency, "value": value} - - -def mk_qty(value: float, unit: str = "contracts") -> dict[str, Any]: - return {"value": value, "unit": unit} - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -@pytest.fixture(scope="module", autouse=True) -def _load_common_schema() -> None: - load_schema("common.schema.json") - - -@pytest.fixture(scope="module") -def market_event_schema() -> dict: - return load_schema("market_event.schema.json") - - -@pytest.fixture(scope="module") -def order_intent_schema() -> dict: - return load_schema("order_intent.schema.json") - - -@pytest.fixture(scope="module") -def risk_constraints_schema() -> dict: - return load_schema("risk_constraints.schema.json") - - -@pytest.fixture(scope="module") -def fill_event_schema() -> dict: - return load_schema("fill_event.schema.json") - - -@pytest.fixture(scope="module") -def order_state_event_schema() -> dict: - return load_schema("order_state_event.schema.json") - - -# --------------------------------------------------------------------------- -# MarketEvent -# --------------------------------------------------------------------------- - -def make_book_event(**book_overrides) -> dict[str, Any]: - book = { - "book_type": "snapshot", - "bids": [{"price": mk_price(100.0), "quantity": mk_qty(1.0)}], - "asks": [{"price": mk_price(100.5), "quantity": mk_qty(1.5)}], - } - book.update(book_overrides) - return { - "ts_ns_local": 123456789, - "ts_ns_exch": 123456089, - "instrument": "BTC-USD", - "event_type": "book", - "book": book, - } - - -def make_trade_event(**trade_overrides) -> dict[str, Any]: - trade = { - "side": "buy", - "price": mk_price(100.25), - "quantity": mk_qty(0.5), - } - trade.update(trade_overrides) - return { - "ts_ns_local": 123456790, - "ts_ns_exch": 123456789, - "instrument": "BTC-USD", - "event_type": "trade", - "trade": trade, - } - - -def test_market_event_book_valid_minimal(market_event_schema): - assert_pydantic_then_schema_ok(MarketEvent, make_book_event(), market_event_schema) - - -def test_market_event_trade_valid_minimal(market_event_schema): - assert_pydantic_then_schema_ok(MarketEvent, make_trade_event(trade_id="T123"), market_event_schema) - - -def test_market_event_enforces_payload_presence(): - with pytest.raises(PydanticValidationError): - pydantic_validate( - MarketEvent, - {"ts_ns_local": 2, "ts_ns_exch": 1, "instrument": "BTC-USD", "event_type": "book"}, - ) - with pytest.raises(PydanticValidationError): - pydantic_validate( - MarketEvent, - {"ts_ns_local": 2, "ts_ns_exch": 1, "instrument": "BTC-USD", "event_type": "trade"}, - ) - - -def test_market_event_rejects_extra_top_level_fields(market_event_schema): - data = make_book_event() - data["unexpected"] = 123 - - with pytest.raises(PydanticValidationError): - pydantic_validate(MarketEvent, data) - - with pytest.raises(JsonSchemaValidationError): - jsonschema_validate(instance=data, schema=market_event_schema, registry=SCHEMA_REGISTRY) - - -def test_market_event_book_depth_optional_valid_values(market_event_schema): - assert_pydantic_then_schema_ok(MarketEvent, make_book_event(depth=0), market_event_schema) - assert_pydantic_then_schema_ok(MarketEvent, make_book_event(depth=1), market_event_schema) - - -def test_market_event_book_depth_negative_rejected(market_event_schema): - bad = make_book_event(depth=-1) - assert_schema_invalid_but_pydantic_rejects(MarketEvent, bad, market_event_schema) - - -def test_market_event_trade_id_min_length(market_event_schema): - assert_pydantic_then_schema_ok(MarketEvent, make_trade_event(trade_id="X"), market_event_schema) - - bad = make_trade_event(trade_id="") - assert_schema_invalid_but_pydantic_rejects(MarketEvent, bad, market_event_schema) - - -def test_market_event_instrument_min_length(market_event_schema): - bad = make_book_event() - bad["instrument"] = "" - assert_schema_invalid_but_pydantic_rejects(MarketEvent, bad, market_event_schema) - - -def test_market_event_ts_ns_exclusive_minimum(market_event_schema): - bad = make_book_event() - bad["ts_ns_local"] = 0 - assert_schema_invalid_but_pydantic_rejects(MarketEvent, bad, market_event_schema) - - -def test_market_event_xor_book_trade_enforced(market_event_schema): - # Schema forbids having both book and trade. - bad = make_book_event() - bad["trade"] = make_trade_event()["trade"] - - with pytest.raises(JsonSchemaValidationError): - jsonschema_validate(instance=bad, schema=market_event_schema, registry=SCHEMA_REGISTRY) - - with pytest.raises(PydanticValidationError): - pydantic_validate(MarketEvent, bad) - - -# --------------------------------------------------------------------------- -# OrderIntent -# --------------------------------------------------------------------------- - -def make_new_intent(**overrides) -> dict[str, Any]: - """ - Build a valid minimal 'new' intent (limit by default). - Note: intended_price is required for both limit and market orders in this system. - """ - data: dict[str, Any] = { - "ts_ns_local": 123456790, - "client_order_id": "C-1", - "instrument": "BTC-USD", - "intent_type": "new", - "order_type": "limit", - "side": "buy", - "intended_qty": mk_qty(1.0), - "intended_price": mk_price(100.0), - "time_in_force": "GTC", - } - data.update(overrides) - return data - - -def make_cancel_intent(**overrides) -> dict[str, Any]: - """ - Build a valid minimal 'cancel' intent. - """ - data: dict[str, Any] = { - "ts_ns_local": 123456791, - "client_order_id": "C-1", - "instrument": "BTC-USD", - "intent_type": "cancel", - } - data.update(overrides) - return data - - -def make_replace_intent(**overrides) -> dict[str, Any]: - """ - Build a valid minimal 'replace' intent (limit-only). - time_in_force is not allowed because it is not modifiable by the execution binding. - """ - data: dict[str, Any] = { - "ts_ns_local": 123456792, - "client_order_id": "C-1", - "instrument": "BTC-USD", - "intent_type": "replace", - "side": "buy", - "order_type": "limit", - "intended_qty": mk_qty(2.0), - "intended_price": mk_price(101.0), - } - data.update(overrides) - return data - - -def test_order_intent_valid_new_limit_with_optional_correlation_id(order_intent_schema): - data = make_new_intent(intents_correlation_id="CORR-1") - assert_pydantic_then_schema_ok(OrderIntent, data, order_intent_schema) - - -def test_order_intent_new_market_requires_intended_price(order_intent_schema): - data = make_new_intent(order_type="market") - assert_pydantic_then_schema_ok(OrderIntent, data, order_intent_schema) - - bad = make_new_intent(order_type="market") - bad.pop("intended_price", None) - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad, order_intent_schema) - - -def test_order_intent_new_limit_requires_intended_price(order_intent_schema): - bad = make_new_intent(order_type="limit") - bad.pop("intended_price", None) - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad, order_intent_schema) - - -def test_order_intent_cancel_valid_minimal(order_intent_schema): - assert_pydantic_then_schema_ok(OrderIntent, make_cancel_intent(), order_intent_schema) - - -def test_order_intent_cancel_forbids_order_fields(order_intent_schema): - # Cancel must not contain order-creation fields. - bad = make_cancel_intent(side="buy") - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad, order_intent_schema) - - bad = make_cancel_intent(order_type="limit") - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad, order_intent_schema) - - bad = make_cancel_intent(intended_qty=1.0) - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad, order_intent_schema) - - bad = make_cancel_intent(intended_price=100.0) - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad, order_intent_schema) - - bad = make_cancel_intent(time_in_force="GTC") - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad, order_intent_schema) - - -def test_order_intent_replace_valid_minimal(order_intent_schema): - assert_pydantic_then_schema_ok(OrderIntent, make_replace_intent(), order_intent_schema) - - -def test_order_intent_replace_requires_limit(order_intent_schema): - bad = make_replace_intent(order_type="market") - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad, order_intent_schema) - - -def test_order_intent_replace_forbids_time_in_force(order_intent_schema): - # Replace must not contain time_in_force (not modifiable). - bad = make_replace_intent(time_in_force="GTC") - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad, order_intent_schema) - - -def test_order_intent_min_length_optionals(order_intent_schema): - bad_corr = make_new_intent(intents_correlation_id="") - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad_corr, order_intent_schema) - - -def test_order_intent_exclusive_minimum_constraints(order_intent_schema): - bad_ts = make_new_intent(ts_ns_local=0) - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad_ts, order_intent_schema) - - bad_price = make_new_intent(intended_price=mk_price(-1.0)) - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad_price, order_intent_schema) - - bad_qty = make_new_intent(intended_qty=mk_qty(-1.0)) - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad_qty, order_intent_schema) - - -def test_order_intent_rejects_additional_properties(order_intent_schema): - data = make_new_intent() - data["unexpected"] = "x" - - with pytest.raises(PydanticValidationError): - pydantic_validate(OrderIntent, data) - - with pytest.raises(JsonSchemaValidationError): - jsonschema_validate(instance=data, schema=order_intent_schema, registry=SCHEMA_REGISTRY) - - -# --------------------------------------------------------------------------- -# RiskConstraints -# --------------------------------------------------------------------------- - -def make_risk_constraints(**overrides) -> dict[str, Any]: - data = { - "ts_ns_local": 987654321, - "scope": "BTC-USD:default", - "trading_enabled": True, - } - data.update(overrides) - return data - - -def test_risk_constraints_valid_with_optionals(risk_constraints_schema): - data = make_risk_constraints( - notional_limits={ - "currency": "USD", - "max_gross_notional": 1_000_000.0, - "max_single_order_notional": 100_000.0, - }, - position_limits={"currency": "BTC", "max_position": 10.0}, - quote_limits={ - "currency": "USD", - "max_gross_quote_notional": 500_000.0, - "max_net_quote_notional": 0.0, - "max_active_quotes": 100, - }, - order_rate_limits={"max_orders_per_second": 50.0, "max_cancels_per_second": 100.0}, - max_loss={"currency": "USD", "max_drawdown": -10_000.0, "rolling_loss": -1000.0, "rolling_loss_window": 60}, - extra={"desk": "alpha", "risk_mode": "conservative", "debug": None}, - ) - assert_pydantic_then_schema_ok(RiskConstraints, data, risk_constraints_schema) - - -def test_risk_constraints_missing_optional_notional_limits_is_ok(risk_constraints_schema): - data = make_risk_constraints() - assert_pydantic_then_schema_ok(RiskConstraints, data, risk_constraints_schema) - - -def test_risk_constraints_minimum_constraints(risk_constraints_schema): - bad_pos = make_risk_constraints(position_limits={"currency": "BTC", "max_position": -1.0}) - assert_schema_invalid_but_pydantic_rejects(RiskConstraints, bad_pos, risk_constraints_schema) - - bad_notional = make_risk_constraints(notional_limits={"currency": "USD", "max_gross_notional": -1.0}) - assert_schema_invalid_but_pydantic_rejects(RiskConstraints, bad_notional, risk_constraints_schema) - - bad_quotes = make_risk_constraints(quote_limits={"currency": "USD", "max_gross_quote_notional": -1.0}) - assert_schema_invalid_but_pydantic_rejects(RiskConstraints, bad_quotes, risk_constraints_schema) - - bad_active = make_risk_constraints(quote_limits={"currency": "USD", "max_gross_quote_notional": 1.0, "max_active_quotes": -1}) - assert_schema_invalid_but_pydantic_rejects(RiskConstraints, bad_active, risk_constraints_schema) - - bad_rate = make_risk_constraints(order_rate_limits={"max_orders_per_second": -0.1}) - assert_schema_invalid_but_pydantic_rejects(RiskConstraints, bad_rate, risk_constraints_schema) - - -def test_risk_constraints_rejects_additional_properties(risk_constraints_schema): - data = make_risk_constraints() - data["unexpected"] = 1 - - with pytest.raises(PydanticValidationError): - pydantic_validate(RiskConstraints, data) - - with pytest.raises(JsonSchemaValidationError): - jsonschema_validate(instance=data, schema=risk_constraints_schema, registry=SCHEMA_REGISTRY) - - -def test_risk_constraints_extra_values_are_schema_compatible(risk_constraints_schema): - data = make_risk_constraints(extra={"ok": "x", "n": 1, "b": True, "z": None}) - assert_pydantic_then_schema_ok(RiskConstraints, data, risk_constraints_schema) - - bad = make_risk_constraints(extra={"nested": {"a": 1}}) - with pytest.raises(JsonSchemaValidationError): - jsonschema_validate(instance=bad, schema=risk_constraints_schema, registry=SCHEMA_REGISTRY) - # Pydantic will reject too because extra is typed as primitive union. - - -# --------------------------------------------------------------------------- -# FillEvent -# --------------------------------------------------------------------------- - -def make_fill(**overrides) -> dict[str, Any]: - data = { - "ts_ns_local": 123456789, - "ts_ns_exch": 123456089, - "instrument": "BTC-USD", - "client_order_id": "C-1", - "side": "buy", - "filled_price": mk_price(100.5), - "cum_filled_qty": mk_qty(0.25), - "time_in_force": "GTC", - "liquidity_flag": "maker", - } - data.update(overrides) - return data - - -def test_fill_event_valid_with_all_optionals(fill_event_schema): - data = make_fill( - intended_price=mk_price(100.0), - intended_qty=mk_qty(1.0), - remaining_qty=mk_qty(0.75), - fee={"currency": "USD", "amount": -0.1}, - ) - assert_pydantic_then_schema_ok(FillEvent, data, fill_event_schema) - - -def test_fill_event_exclusive_minimum_constraints(fill_event_schema): - bad_ts = make_fill(ts_ns_local=0) - assert_schema_invalid_but_pydantic_rejects(FillEvent, bad_ts, fill_event_schema) - - -def test_fill_event_min_length_optionals(fill_event_schema): - bad_order_id = make_fill(client_order_id="") - assert_schema_invalid_but_pydantic_rejects(FillEvent, bad_order_id, fill_event_schema) - - -def test_fill_event_rejects_additional_properties(fill_event_schema): - data = make_fill() - data["unexpected"] = "x" - - with pytest.raises(PydanticValidationError): - pydantic_validate(FillEvent, data) - - with pytest.raises(JsonSchemaValidationError): - jsonschema_validate(instance=data, schema=fill_event_schema, registry=SCHEMA_REGISTRY) - - -# --------------------------------------------------------------------------- -# OrderStateEvent -# --------------------------------------------------------------------------- - -def make_state(**overrides) -> dict[str, Any]: - data = { - "ts_ns_local": 123456789, - "ts_ns_exch": 123456089, - "instrument": "BTC-USD", - "client_order_id": "C-1", - "order_type": "limit", - "state_type": "accepted", - "side": "buy", - "intended_price": mk_price(100.0), - "intended_qty": mk_qty(1.0), - "time_in_force": "GTC", - } - data.update(overrides) - return data - - -def test_order_state_event_valid_with_all_optionals(order_state_event_schema): - data = make_state( - filled_price=mk_price(100.1), - cum_filled_qty=mk_qty(0.5), - remaining_qty=mk_qty(0.5), - reason="Partial fill", - raw={"venue_status": "PARTIAL"}, - ) - assert_pydantic_then_schema_ok(OrderStateEvent, data, order_state_event_schema) - - -def test_order_state_event_min_length_optionals(order_state_event_schema): - bad_order_id = make_state(client_order_id="") - assert_schema_invalid_but_pydantic_rejects(OrderStateEvent, bad_order_id, order_state_event_schema) - - bad_reason = make_state(reason="") - assert_schema_invalid_but_pydantic_rejects(OrderStateEvent, bad_reason, order_state_event_schema) - - -def test_order_state_event_exclusive_minimum_ts(order_state_event_schema): - bad = make_state(ts_ns_local=0) - assert_schema_invalid_but_pydantic_rejects(OrderStateEvent, bad, order_state_event_schema) - - -def test_order_state_event_rejects_additional_properties(order_state_event_schema): - data = make_state() - data["unexpected"] = 1 - - with pytest.raises(PydanticValidationError): - pydantic_validate(OrderStateEvent, data) - - with pytest.raises(JsonSchemaValidationError): - jsonschema_validate(instance=data, schema=order_state_event_schema, registry=SCHEMA_REGISTRY) diff --git a/tests/semantics/models/test_processing_position_cursor_ownership_guard.py b/tests/semantics/models/test_processing_position_cursor_ownership_guard.py deleted file mode 100644 index 429d3ae..0000000 --- a/tests/semantics/models/test_processing_position_cursor_ownership_guard.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Architectural guard for ProcessingPosition cursor ownership.""" - -from __future__ import annotations - -import ast -from pathlib import Path - -_ALLOWED_CALLER = Path("tradingchassis_core/core/domain/processing.py") -_ALLOWED_MUTATION_FILE = Path("tradingchassis_core/core/domain/state.py") -_TARGET_METHOD = "_advance_processing_position" -_TARGET_ATTR = "_last_processing_position_index" -_POSITIONED_MARKET_TARGET_METHOD = "_update_market_from_positioned_canonical_event" - - -def _iter_python_files(root: Path) -> list[Path]: - return sorted(path for path in root.rglob("*.py") if path.is_file()) - - -def _find_target_method_calls(path: Path) -> list[tuple[int, int]]: - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - calls: list[tuple[int, int]] = [] - - for node in ast.walk(tree): - if not isinstance(node, ast.Call): - continue - if not isinstance(node.func, ast.Attribute): - continue - if node.func.attr != _TARGET_METHOD: - continue - calls.append((node.lineno, node.col_offset)) - - return calls - - -def _find_positioned_market_target_method_calls(path: Path) -> list[tuple[int, int]]: - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - calls: list[tuple[int, int]] = [] - - for node in ast.walk(tree): - if not isinstance(node, ast.Call): - continue - if not isinstance(node.func, ast.Attribute): - continue - if node.func.attr != _POSITIONED_MARKET_TARGET_METHOD: - continue - calls.append((node.lineno, node.col_offset)) - - return calls - - -def _find_target_attr_mutations(path: Path) -> list[tuple[int, int]]: - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - writes: list[tuple[int, int]] = [] - - for node in ast.walk(tree): - if isinstance(node, ast.Assign): - targets = node.targets - elif isinstance(node, ast.AnnAssign): - targets = [node.target] - elif isinstance(node, ast.AugAssign): - targets = [node.target] - else: - continue - - for target in targets: - if isinstance(target, ast.Attribute) and target.attr == _TARGET_ATTR: - writes.append((target.lineno, target.col_offset)) - - return writes - - -def test_processing_position_cursor_is_mutated_only_via_canonical_boundary() -> None: - repo_root = Path(__file__).resolve().parents[3] - production_root = repo_root / "tradingchassis_core" - - call_violations: list[str] = [] - mutation_violations: list[str] = [] - - for file_path in _iter_python_files(production_root): - relative_path = file_path.relative_to(repo_root) - - method_calls = _find_target_method_calls(file_path) - if method_calls and relative_path != _ALLOWED_CALLER: - for lineno, col in method_calls: - call_violations.append( - f"{relative_path}:{lineno}:{col} calls {_TARGET_METHOD}(...)" - ) - - attr_writes = _find_target_attr_mutations(file_path) - if attr_writes and relative_path != _ALLOWED_MUTATION_FILE: - for lineno, col in attr_writes: - mutation_violations.append( - f"{relative_path}:{lineno}:{col} writes {_TARGET_ATTR}" - ) - - assert not call_violations, ( - "Unexpected ProcessingPosition cursor helper calls outside canonical boundary:\n" - + "\n".join(call_violations) - ) - assert not mutation_violations, ( - "Unexpected ProcessingPosition cursor mutations outside StrategyState:\n" - + "\n".join(mutation_violations) - ) - - -def test_positioned_market_helper_is_called_only_via_canonical_boundary() -> None: - repo_root = Path(__file__).resolve().parents[3] - production_root = repo_root / "tradingchassis_core" - - call_violations: list[str] = [] - - for file_path in _iter_python_files(production_root): - relative_path = file_path.relative_to(repo_root) - method_calls = _find_positioned_market_target_method_calls(file_path) - if method_calls and relative_path != _ALLOWED_CALLER: - for lineno, col in method_calls: - call_violations.append( - f"{relative_path}:{lineno}:{col} calls " - f"{_POSITIONED_MARKET_TARGET_METHOD}(...)" - ) - - assert not call_violations, ( - "Unexpected positioned market helper calls outside canonical boundary:\n" - + "\n".join(call_violations) - ) diff --git a/tests/semantics/models/test_public_canonical_api_surface.py b/tests/semantics/models/test_public_canonical_api_surface.py deleted file mode 100644 index 0244701..0000000 --- a/tests/semantics/models/test_public_canonical_api_surface.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Public import-surface contract for canonical core processing APIs.""" - -from __future__ import annotations - -import tradingchassis_core as tc -from tradingchassis_core.core.domain.configuration import CoreConfiguration -from tradingchassis_core.core.domain.processing import ( - fold_event_stream_entries, - process_event_entry, -) -from tradingchassis_core.core.domain.processing_order import EventStreamEntry, ProcessingPosition -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ControlTimeEvent -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def test_public_root_exposes_canonical_processing_symbols() -> None: - assert hasattr(tc, "CoreConfiguration") - assert hasattr(tc, "ProcessingPosition") - assert hasattr(tc, "EventStreamEntry") - assert hasattr(tc, "process_event_entry") - assert hasattr(tc, "fold_event_stream_entries") - - -def test_public_root_canonical_processing_symbols_have_identity_with_deep_implementations() -> None: - assert tc.CoreConfiguration is CoreConfiguration - assert tc.ProcessingPosition is ProcessingPosition - assert tc.EventStreamEntry is EventStreamEntry - assert tc.process_event_entry is process_event_entry - assert tc.fold_event_stream_entries is fold_event_stream_entries - - -def test_public_process_event_entry_smoke_for_non_market_canonical_event() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = tc.EventStreamEntry( - position=tc.ProcessingPosition(index=0), - event=ControlTimeEvent( - ts_ns_local_control=100, - reason="rate_limit_recheck", - due_ts_ns_local=110, - realized_ts_ns_local=None, - obligation_reason="rate_limit", - obligation_due_ts_ns_local=110, - runtime_correlation={"engine": "test", "seq": 1}, - ), - ) - - tc.process_event_entry(state, entry) - - assert state._last_processing_position_index == 0 diff --git a/tests/semantics/queue_semantics/test_control_scheduling_obligation_characterization.py b/tests/semantics/queue_semantics/test_control_scheduling_obligation_characterization.py deleted file mode 100644 index e886c86..0000000 --- a/tests/semantics/queue_semantics/test_control_scheduling_obligation_characterization.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Characterization tests for internal scheduling obligation mapping.""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - CancelOrderIntent, - NewOrderIntent, - NotionalLimits, - OrderRateLimits, - OrderStateEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.execution_control import ExecutionControl -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def test_rate_limited_mixed_intents_keep_minimum_next_send_timestamp() -> None: - """Compatibility: next_send_ts remains the minimum blocked wake timestamp.""" - - instrument = "BTC-USDC-PERP" - new_client_order_id = "order-new" - cancel_client_order_id = "order-cancel" - state = StrategyState(event_bus=NullEventBus()) - - # CANCEL requires a known working order to pass existence gating. - state.apply_order_state_event( - OrderStateEvent( - ts_ns_exch=1, - ts_ns_local=1, - instrument=instrument, - client_order_id=cancel_client_order_id, - order_type="limit", - state_type="working", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - ) - - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - order_rate_limits=OrderRateLimits( - max_orders_per_second=0, # wake: next second boundary at 1_000_000_000 - max_cancels_per_second=2, # wake: 0.5s at 500_000_000 - ), - ) - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - new_intent = NewOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id=new_client_order_id, - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - cancel_intent = CancelOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id=cancel_client_order_id, - intents_correlation_id=None, - ) - - decision = risk_engine.decide_intents( - raw_intents=[new_intent, cancel_intent], - state=state, - now_ts_ns_local=1, - ) - - assert decision.accepted_now == [] - assert decision.rejected == [] - assert len(decision.queued) == 2 - assert decision.next_send_ts_ns_local == 500_000_000 - - -def test_rate_limit_routing_sets_internal_obligation_reason_characterization() -> None: - """Internal semantic contract: rate-limit blocking emits a rate_limit obligation.""" - - execution_control = ExecutionControl() - new_intent = NewOrderIntent( - ts_ns_local=1, - instrument="BTC-USDC-PERP", - client_order_id="order-1", - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - result = execution_control.route_after_policy_rate_limit( - new_intent, - now_ts_ns_local=1, - max_orders_per_sec=0, - max_cancels_per_sec=None, - ) - - assert result.accept_now is False - assert result.stage_to_queue is True - assert result.scheduling_obligation is not None - assert result.scheduling_obligation.ts_ns_local == 1_000_000_000 - assert result.scheduling_obligation.reason == "rate_limit" - diff --git a/tests/semantics/queue_semantics/test_mixed_queued_intent_dominance_sequences_characterization.py b/tests/semantics/queue_semantics/test_mixed_queued_intent_dominance_sequences_characterization.py deleted file mode 100644 index 04c091b..0000000 --- a/tests/semantics/queue_semantics/test_mixed_queued_intent_dominance_sequences_characterization.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -Characterization tests: mixed dominance sequences within queued intents. - -These pin the current behavior for sequences like: -NEW queued -> REPLACE on same logical key -> CANCEL on same logical key - -This suite is intentionally descriptive of current behavior (not prescriptive). -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - CancelOrderIntent, - NewOrderIntent, - NotionalLimits, - OrderRateLimits, - Price, - Quantity, - ReplaceOrderIntent, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def test_new_then_replace_then_cancel_on_same_key_characterization() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - order_rate_limits=OrderRateLimits( - max_orders_per_second=0, - max_cancels_per_second=0, - ), - ) - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - # Step 1: NEW is queued due to order rate-limit. - new_intent = NewOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - d1 = risk_engine.decide_intents(raw_intents=[new_intent], state=state, now_ts_ns_local=1) - assert d1.accepted_now == [] - assert d1.rejected == [] - assert [it.intent_type for it in d1.queued] == ["new"] - assert state.has_queued_intent(instrument, client_order_id) - - # Step 2: REPLACE arrives while there is no working order, but a queued NEW exists. - # Characterization: the REPLACE is handled locally and results in an updated queued NEW. - replace_intent = ReplaceOrderIntent( - ts_ns_local=2, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - side="buy", - intended_price=Price(currency="USDC", value=101.0), - intended_qty=Quantity(unit="contracts", value=2.0), - ) - d2 = risk_engine.decide_intents(raw_intents=[replace_intent], state=state, now_ts_ns_local=2) - assert d2.accepted_now == [] - assert d2.rejected == [] - assert len(d2.handled_in_queue) == 1 - assert d2.handled_in_queue[0].intent_type == "replace" - assert [it.intent_type for it in d2.queued] == ["new"] - - queued_new = state.find_queued_new_intent(instrument, client_order_id) - assert queued_new is not None - assert queued_new.intended_price.value == 101.0 - assert queued_new.intended_qty.value == 2.0 - - # Step 3: CANCEL arrives while only queued state exists (no working order). - # Characterization: the CANCEL clears queued intents for that key and is handled locally. - cancel_intent = CancelOrderIntent( - ts_ns_local=3, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - ) - d3 = risk_engine.decide_intents(raw_intents=[cancel_intent], state=state, now_ts_ns_local=3) - assert d3.accepted_now == [] - assert d3.rejected == [] - assert len(d3.handled_in_queue) == 1 - assert d3.handled_in_queue[0].intent_type == "cancel" - assert d3.queued == [] - assert not state.has_queued_intent(instrument, client_order_id) diff --git a/tests/semantics/queue_semantics/test_new_queued_on_rate_limit.py b/tests/semantics/queue_semantics/test_new_queued_on_rate_limit.py deleted file mode 100644 index 6d4ecd8..0000000 --- a/tests/semantics/queue_semantics/test_new_queued_on_rate_limit.py +++ /dev/null @@ -1,134 +0,0 @@ -""" -Semantic test: NEW intents are queued under active rate-limit backpressure. - -Invariant: -Queue is a backpressure buffer. NEW intents must be queued (not accepted_now) -if a temporary send blocker exists (e.g. rate limit exhausted). -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - CancelOrderIntent, - NewOrderIntent, - NotionalLimits, - OrderRateLimits, - OrderStateEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def test_new_is_queued_when_rate_limit_blocks() -> None: - """NEW intent must be queued when order rate-limit blocks sending.""" - - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # Configure strict order rate-limit to force backpressure - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - order_rate_limits=OrderRateLimits( - max_orders_per_second=0, - ), - ) - - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - new_intent = NewOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - decision = risk_engine.decide_intents( - raw_intents=[new_intent], - state=state, - now_ts_ns_local=1, - ) - - assert decision.accepted_now == [] - assert decision.rejected == [] - assert len(decision.queued) == 1 - # Characterization: rate-limit backpressure sets a "wake up no earlier than" timestamp. - # With ts_ns_local=1 and max_orders_per_second=0, wake timestamp is next local-second boundary. - assert decision.next_send_ts_ns_local == 1_000_000_000 - - -def test_cancel_is_queued_when_cancel_rate_limit_blocks_and_sets_next_send_characterization() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # A CANCEL only passes existence gating if a working order exists. - state.apply_order_state_event( - OrderStateEvent( - ts_ns_exch=1, - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type="working", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - ) - - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - order_rate_limits=OrderRateLimits( - max_cancels_per_second=0, - ), - ) - - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - cancel_intent = CancelOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - ) - - decision = risk_engine.decide_intents( - raw_intents=[cancel_intent], - state=state, - now_ts_ns_local=1, - ) - - assert decision.accepted_now == [] - assert decision.rejected == [] - assert [it.intent_type for it in decision.queued] == ["cancel"] - assert decision.next_send_ts_ns_local == 1_000_000_000 diff --git a/tests/semantics/queue_semantics/test_queue_cancel_dominates_new.py b/tests/semantics/queue_semantics/test_queue_cancel_dominates_new.py deleted file mode 100644 index 428b4d9..0000000 --- a/tests/semantics/queue_semantics/test_queue_cancel_dominates_new.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -Semantic test: CANCEL dominates queued NEW intents. - -Invariant: -If a NEW intent is already queued for an order id and a CANCEL intent -for the same order id arrives while the order is still not inflight, -the queued NEW must be removed and replaced by the CANCEL. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - CancelOrderIntent, - NewOrderIntent, - NotionalLimits, - OrderRateLimits, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def test_cancel_dominates_queued_new() -> None: - """CANCEL must remove a queued NEW for the same order id.""" - - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # Configure rate-limit to force backpressure (queueing) - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - order_rate_limits=OrderRateLimits( - max_orders_per_second=0, - ), - ) - - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - # Step 1: NEW intent is queued due to rate-limit backpressure - new_intent = NewOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - decision_1 = risk_engine.decide_intents( - raw_intents=[new_intent], - state=state, - now_ts_ns_local=1, - ) - - assert decision_1.accepted_now == [] - assert decision_1.rejected == [] - assert len(decision_1.queued) == 1 - - # Step 2: CANCEL arrives for the same order id - cancel_intent = CancelOrderIntent( - ts_ns_local=2, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - ) - - decision_2 = risk_engine.decide_intents( - raw_intents=[cancel_intent], - state=state, - now_ts_ns_local=2, - ) - - # --- Queue mutation assertions --- - assert decision_2.accepted_now == [] - assert decision_2.rejected == [] - assert len(decision_2.handled_in_queue) == 1 - assert decision_2.queued == [] - - # Queue must contain only CANCEL - assert not state.has_queued_intent(instrument, client_order_id) diff --git a/tests/semantics/queue_semantics/test_strategy_state_pop_queued_intents_characterization.py b/tests/semantics/queue_semantics/test_strategy_state_pop_queued_intents_characterization.py deleted file mode 100644 index 5bf4505..0000000 --- a/tests/semantics/queue_semantics/test_strategy_state_pop_queued_intents_characterization.py +++ /dev/null @@ -1,158 +0,0 @@ -""" -Characterization tests: StrategyState outbox queue pop semantics. - -These tests pin the *current* behavior of: -- StrategyState.pop_queued_intents ordering (priority then FIFO) -- inflight filtering (skips blocked ids without dequeuing them) - -This suite is intentionally explicit and should not be interpreted as desired semantics. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - CancelOrderIntent, - NewOrderIntent, - Price, - Quantity, - ReplaceOrderIntent, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def test_pop_queued_intents_orders_by_priority_then_fifo_characterization() -> None: - instrument = "BTC-USDC-PERP" - - state = StrategyState(event_bus=NullEventBus()) - - new_1 = NewOrderIntent( - ts_ns_local=30, - instrument=instrument, - client_order_id="new-1", - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - new_2 = NewOrderIntent( - ts_ns_local=10, - instrument=instrument, - client_order_id="new-2", - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=101.0), - time_in_force="GTC", - ) - replace_1 = ReplaceOrderIntent( - ts_ns_local=20, - instrument=instrument, - client_order_id="replace-1", - intents_correlation_id=None, - side="buy", - intended_price=Price(currency="USDC", value=102.0), - intended_qty=Quantity(unit="contracts", value=1.0), - ) - cancel_1 = CancelOrderIntent( - ts_ns_local=40, - instrument=instrument, - client_order_id="cancel-1", - intents_correlation_id=None, - ) - cancel_2 = CancelOrderIntent( - ts_ns_local=5, - instrument=instrument, - client_order_id="cancel-2", - intents_correlation_id=None, - ) - - state.merge_intents_into_queue( - instrument=instrument, - intents=[new_1, replace_1, cancel_1, new_2, cancel_2], - ) - - popped = state.pop_queued_intents(instrument) - popped_ids = [it.client_order_id for it in popped] - - # Characterization: selection is computed by priority + queued_at_ts_ns, - # but the returned list preserves the queue's iteration order for the selected set. - # Since all intents are eligible here, this matches enqueue order. - assert popped_ids == ["new-1", "replace-1", "cancel-1", "new-2", "cancel-2"] - - -def test_pop_queued_intents_filters_inflight_without_dequeuing_characterization() -> None: - instrument = "BTC-USDC-PERP" - - state = StrategyState(event_bus=NullEventBus()) - - blocked_new = NewOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id="blocked", - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - allowed_cancel = CancelOrderIntent( - ts_ns_local=2, - instrument=instrument, - client_order_id="allowed", - intents_correlation_id=None, - ) - - state.merge_intents_into_queue(instrument=instrument, intents=[blocked_new, allowed_cancel]) - - state.mark_intent_sent( - instrument=instrument, - client_order_id="blocked", - intent_type="new", - ) - - popped_1 = state.pop_queued_intents(instrument) - assert [it.client_order_id for it in popped_1] == ["allowed"] - - # Characterization: the inflight-blocked intent remains queued (not removed). - assert state.has_queued_intent(instrument, "blocked") - assert not state.has_queued_intent(instrument, "allowed") - - # After inflight clears, it becomes eligible. - state._clear_inflight(instrument=instrument, client_order_id="blocked") - popped_2 = state.pop_queued_intents(instrument) - assert [it.client_order_id for it in popped_2] == ["blocked"] - - -def test_pop_queued_intents_respects_max_items_characterization() -> None: - instrument = "BTC-USDC-PERP" - state = StrategyState(event_bus=NullEventBus()) - - cancel_a = CancelOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id="a", - intents_correlation_id=None, - ) - cancel_b = CancelOrderIntent( - ts_ns_local=2, - instrument=instrument, - client_order_id="b", - intents_correlation_id=None, - ) - cancel_c = CancelOrderIntent( - ts_ns_local=3, - instrument=instrument, - client_order_id="c", - intents_correlation_id=None, - ) - - state.merge_intents_into_queue(instrument=instrument, intents=[cancel_c, cancel_a, cancel_b]) - - popped = state.pop_queued_intents(instrument, max_items=2) - assert [it.client_order_id for it in popped] == ["a", "b"] - assert state.has_queued_intent(instrument, "c") diff --git a/tests/semantics/state_transitions/test_new_to_working.py b/tests/semantics/state_transitions/test_new_to_working.py deleted file mode 100644 index 7adb2cd..0000000 --- a/tests/semantics/state_transitions/test_new_to_working.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Semantic test: NEW -> working. - -Invariant: -After a NEW intent has been sent (accepted_now), -the order must be present in the working orders state. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def test_new_transitions_to_working() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # Simulate that the order is acknowledged as working - state.orders[instrument] = { - client_order_id: { - "status": "working", - } - } - - assert instrument in state.orders - assert client_order_id in state.orders[instrument] - assert state.orders[instrument][client_order_id]["status"] == "working" diff --git a/tests/semantics/state_transitions/test_replace_to_replaced.py b/tests/semantics/state_transitions/test_replace_to_replaced.py deleted file mode 100644 index d00004d..0000000 --- a/tests/semantics/state_transitions/test_replace_to_replaced.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -Semantic test: replace -> replaced. - -Invariant: -A replace operation must not result in duplicate working orders -for the same client_order_id. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def test_replace_transitions_to_replaced() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # Existing working order - state.orders[instrument] = { - client_order_id: { - "status": "working", - "version": 1, - } - } - - # Simulate replace acknowledgment - state.orders[instrument][client_order_id] = { - "status": "working", - "version": 2, - } - - assert len(state.orders[instrument]) == 1 - assert state.orders[instrument][client_order_id]["version"] == 2 diff --git a/tests/semantics/state_transitions/test_submitted_boundary_characterization.py b/tests/semantics/state_transitions/test_submitted_boundary_characterization.py deleted file mode 100644 index 6179981..0000000 --- a/tests/semantics/state_transitions/test_submitted_boundary_characterization.py +++ /dev/null @@ -1,619 +0,0 @@ -""" -Characterization and semantic tests for submitted boundary behavior. - -This suite pins compatibility behavior while introducing an internal canonical -order lifecycle projection that begins at dispatch/submission. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - NewOrderIntent, - OrderStateEvent, - OrderSubmittedEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def _new_intent(instrument: str, client_order_id: str, *, ts_ns_local: int) -> NewOrderIntent: - return NewOrderIntent( - ts_ns_local=ts_ns_local, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - -def _order_state_event( - instrument: str, - client_order_id: str, - *, - ts_ns_local: int, - ts_ns_exch: int, - state_type: str, - req: int = 0, -) -> OrderStateEvent: - return OrderStateEvent( - ts_ns_exch=ts_ns_exch, - ts_ns_local=ts_ns_local, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type=state_type, - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": req, "source": "snapshot"}, - ) - - -def _order_submitted_event( - instrument: str, - client_order_id: str, - *, - ts_ns_local_dispatch: int, -) -> OrderSubmittedEvent: - return OrderSubmittedEvent( - ts_ns_local_dispatch=ts_ns_local_dispatch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - order_type="limit", - intended_price=Price(currency="USDC", value=100.0), - intended_qty=Quantity(unit="contracts", value=1.0), - time_in_force="GTC", - intent_correlation_id="corr-1", - dispatch_attempt_id="attempt-1", - runtime_correlation={"engine": "backtest", "seq": 1}, - ) - - -def test_mark_intent_sent_new_preserves_inflight_compatibility_characterization() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-new-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(101) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - assert state.has_inflight(instrument, client_order_id) - assert state.inflight[instrument][client_order_id].action == "new" - assert state.inflight[instrument][client_order_id].ts_sent_ns_local == 101 - assert state.last_sent_intents[instrument][client_order_id] == (101, "new") - - -def test_mark_intent_sent_new_does_not_mutate_existing_strategy_state_orders_characterization() -> None: - instrument = "BTC-USDC-PERP" - existing_order_id = "existing-order-1" - state = StrategyState(event_bus=NullEventBus()) - - state.apply_order_state_event( - _order_state_event( - instrument, - existing_order_id, - ts_ns_local=100, - ts_ns_exch=100, - state_type="working", - ) - ) - before = state.orders[instrument][existing_order_id] - - state.update_timestamp(150) - state.mark_intent_sent(instrument=instrument, client_order_id="new-order-1", intent_type="new") - - assert state.orders[instrument][existing_order_id] is before - assert state.orders[instrument][existing_order_id].state_type == "working" - - -def test_strategy_state_orders_remains_snapshot_driven_characterization() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-snapshot-driven-1" - state = StrategyState(event_bus=NullEventBus()) - - state.merge_intents_into_queue( - instrument=instrument, - intents=[_new_intent(instrument, client_order_id, ts_ns_local=10)], - ) - state.update_timestamp(11) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - assert not state.has_working_order(instrument, client_order_id) - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=12, - ts_ns_exch=12, - state_type="working", - ) - ) - assert state.has_working_order(instrument, client_order_id) - - -def test_none_to_pending_new_compatibility_transition_remains_valid_characterization() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-pending-new-1" - state = StrategyState(event_bus=NullEventBus()) - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=200, - ts_ns_exch=200, - state_type="pending_new", - req=1, - ) - ) - - assert state.orders[instrument][client_order_id].state_type == "pending_new" - - -def test_mark_intent_sent_new_creates_canonical_submitted_projection() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(300) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.instrument == instrument - assert projection.client_order_id == client_order_id - assert projection.state == "submitted" - assert projection.submitted_ts_ns_local == 300 - assert projection.updated_ts_ns_local == 300 - - -def test_mark_intent_sent_new_does_not_advance_processing_position_cursor() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-cursor-guard-1" - state = StrategyState(event_bus=NullEventBus()) - - assert state._last_processing_position_index is None - - state.update_timestamp(301) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - # mark_intent_sent sidecar behavior remains available without canonical entry metadata. - assert state.canonical_orders[(instrument, client_order_id)].state == "submitted" - assert state.has_inflight(instrument, client_order_id) - assert state._last_processing_position_index is None - - -def test_order_submitted_event_creates_projection_without_mark_intent_sent() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-stream-submitted-1" - state = StrategyState(event_bus=NullEventBus()) - - state.apply_order_submitted_event( - _order_submitted_event( - instrument, - client_order_id, - ts_ns_local_dispatch=305, - ) - ) - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "submitted" - assert projection.submitted_ts_ns_local == 305 - assert projection.updated_ts_ns_local == 305 - assert state.orders == {} - - -def test_mark_intent_sent_new_remains_unchanged_when_projection_preexists() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-coexistence-1" - state = StrategyState(event_bus=NullEventBus()) - - state.apply_order_submitted_event( - _order_submitted_event( - instrument, - client_order_id, - ts_ns_local_dispatch=310, - ) - ) - before = state.canonical_orders[(instrument, client_order_id)] - - state.update_timestamp(320) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - after = state.canonical_orders[(instrument, client_order_id)] - assert after.submitted_ts_ns_local == before.submitted_ts_ns_local - assert after.updated_ts_ns_local == before.updated_ts_ns_local - assert after.state == "submitted" - # Existing compatibility bookkeeping behavior remains intact. - assert state.has_inflight(instrument, client_order_id) - assert state.last_sent_intents[instrument][client_order_id] == (320, "new") - - -def test_queue_residency_alone_does_not_create_canonical_order() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-queued-only-1" - state = StrategyState(event_bus=NullEventBus()) - - state.merge_intents_into_queue( - instrument=instrument, - intents=[_new_intent(instrument, client_order_id, ts_ns_local=1)], - ) - - assert state.canonical_orders == {} - - -def test_mark_intent_sent_replace_and_cancel_do_not_create_canonical_submitted_order() -> None: - instrument = "BTC-USDC-PERP" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(400) - state.mark_intent_sent(instrument=instrument, client_order_id="existing-1", intent_type="new") - state.apply_order_state_event( - _order_state_event( - instrument, - "existing-1", - ts_ns_local=410, - ts_ns_exch=410, - state_type="working", - ) - ) - - state.mark_intent_sent(instrument=instrument, client_order_id="replace-1", intent_type="replace") - state.mark_intent_sent(instrument=instrument, client_order_id="cancel-1", intent_type="cancel") - state.mark_intent_sent(instrument=instrument, client_order_id="existing-1", intent_type="replace") - state.mark_intent_sent(instrument=instrument, client_order_id="existing-1", intent_type="cancel") - - assert (instrument, "replace-1") not in state.canonical_orders - assert (instrument, "cancel-1") not in state.canonical_orders - assert state.canonical_orders[(instrument, "existing-1")].state == "accepted" - - -def test_post_dispatch_feedback_advances_existing_canonical_projection() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-advance-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(500) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - assert state.canonical_orders[(instrument, client_order_id)].state == "submitted" - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=550, - ts_ns_exch=550, - state_type="working", - ) - ) - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "accepted" - assert projection.updated_ts_ns_local == 550 - assert state.orders[instrument][client_order_id].state_type == "working" - - -def test_pending_new_does_not_advance_canonical_submitted_projection() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-pending-new-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(600) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - before = state.canonical_orders[(instrument, client_order_id)] - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=610, - ts_ns_exch=610, - state_type="pending_new", - req=1, - ) - ) - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "submitted" - assert projection.updated_ts_ns_local == before.updated_ts_ns_local - assert state.orders[instrument][client_order_id].state_type == "pending_new" - - -def test_accepted_advances_submitted_to_accepted() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-accepted-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(700) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=710, - ts_ns_exch=710, - state_type="accepted", - ) - ) - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "accepted" - assert projection.updated_ts_ns_local == 710 - assert state.orders[instrument][client_order_id].state_type == "accepted" - - -def test_rejected_advances_submitted_to_rejected_terminal() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-rejected-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(800) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=810, - ts_ns_exch=810, - state_type="rejected", - ) - ) - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "rejected" - assert projection.updated_ts_ns_local == 810 - assert client_order_id not in state.orders.get(instrument, {}) - - -def test_partially_filled_and_filled_canonical_progression() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-fill-progression-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(900) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=910, - ts_ns_exch=910, - state_type="working", - ) - ) - assert state.canonical_orders[(instrument, client_order_id)].state == "accepted" - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=920, - ts_ns_exch=920, - state_type="partially_filled", - ) - ) - assert state.canonical_orders[(instrument, client_order_id)].state == "partially_filled" - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=930, - ts_ns_exch=930, - state_type="filled", - ) - ) - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "filled" - assert projection.updated_ts_ns_local == 930 - assert client_order_id not in state.orders.get(instrument, {}) - - -def test_partially_filled_to_canceled_canonical_progression() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-cancel-progression-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(950) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=960, - ts_ns_exch=960, - state_type="accepted", - ) - ) - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=970, - ts_ns_exch=970, - state_type="partially_filled", - ) - ) - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=980, - ts_ns_exch=980, - state_type="canceled", - ) - ) - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "canceled" - assert projection.updated_ts_ns_local == 980 - assert client_order_id not in state.orders.get(instrument, {}) - - -def test_terminal_canonical_state_is_final_noop_on_later_updates() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-terminal-final-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(1000) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=1010, - ts_ns_exch=1010, - state_type="working", - ) - ) - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=1020, - ts_ns_exch=1020, - state_type="filled", - ) - ) - assert state.canonical_orders[(instrument, client_order_id)].state == "filled" - assert state.canonical_orders[(instrument, client_order_id)].updated_ts_ns_local == 1020 - - # Invalid terminal transition should remain a no-op for canonical state. - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=1030, - ts_ns_exch=1030, - state_type="canceled", - ) - ) - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "filled" - assert projection.updated_ts_ns_local == 1020 - - -def test_replaced_does_not_advance_canonical_lifecycle() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-replaced-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(1100) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=1110, - ts_ns_exch=1110, - state_type="working", - ) - ) - before = state.canonical_orders[(instrument, client_order_id)] - assert before.state == "accepted" - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=1120, - ts_ns_exch=1120, - state_type="replaced", - ) - ) - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "accepted" - assert projection.updated_ts_ns_local == 1110 - - -def test_expired_does_not_introduce_canonical_expired_state() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-expired-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(1200) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=1210, - ts_ns_exch=1210, - state_type="expired", - ) - ) - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "submitted" - assert projection.updated_ts_ns_local == 1200 - assert client_order_id not in state.orders.get(instrument, {}) - - -def test_snapshot_fill_progression_does_not_mutate_canonical_fill_reducer_buckets() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-snapshot-fill-guard-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(1300) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - first_partial = _order_state_event( - instrument, - client_order_id, - ts_ns_local=1310, - ts_ns_exch=1310, - state_type="partially_filled", - ).model_copy( - update={ - "filled_price": Price(currency="USDC", value=100.25), - "cum_filled_qty": Quantity(unit="contracts", value=0.25), - "remaining_qty": Quantity(unit="contracts", value=0.75), - } - ) - second_partial = _order_state_event( - instrument, - client_order_id, - ts_ns_local=1320, - ts_ns_exch=1320, - state_type="partially_filled", - ).model_copy( - update={ - "filled_price": Price(currency="USDC", value=100.50), - "cum_filled_qty": Quantity(unit="contracts", value=0.50), - "remaining_qty": Quantity(unit="contracts", value=0.50), - } - ) - - state.apply_order_state_event(first_partial) - state.apply_order_state_event(second_partial) - - # Compatibility snapshot/projection path remains active. - assert state.orders[instrument][client_order_id].state_type == "partially_filled" - assert state.orders[instrument][client_order_id].cum_filled_qty == 0.50 - assert state.canonical_orders[(instrument, client_order_id)].state == "submitted" - assert state.canonical_orders[(instrument, client_order_id)].updated_ts_ns_local == 1300 - - # Snapshot progression must not mutate canonical FillEvent reducer buckets. - assert state.fills == {} - assert state.fill_cum_qty == {} diff --git a/tests/semantics/state_transitions/test_terminal_clears_inflight.py b/tests/semantics/state_transitions/test_terminal_clears_inflight.py deleted file mode 100644 index 6bada2c..0000000 --- a/tests/semantics/state_transitions/test_terminal_clears_inflight.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -Semantic test: terminal clears inflight. - -Invariant: -Any terminal order event must clear inflight state. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def test_terminal_clears_inflight() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # Precondition: order is inflight - state.inflight[instrument] = {client_order_id} - - # Simulate terminal event cleanup - state.inflight[instrument].discard(client_order_id) - - assert client_order_id not in state.inflight.get(instrument, set()) diff --git a/tests/semantics/state_transitions/test_working_to_filled.py b/tests/semantics/state_transitions/test_working_to_filled.py deleted file mode 100644 index de5a89f..0000000 --- a/tests/semantics/state_transitions/test_working_to_filled.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Semantic test: working -> filled. - -Invariant: -A filled order must be removed from the working orders state. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def test_working_transitions_to_filled() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # Precondition: order is working - state.orders[instrument] = { - client_order_id: { - "status": "working", - } - } - - # Simulate terminal fill - del state.orders[instrument][client_order_id] - - assert client_order_id not in state.orders.get(instrument, {}) diff --git a/tests/semantics/test_control_time_scheduling_semantics.py b/tests/semantics/test_control_time_scheduling_semantics.py new file mode 100644 index 0000000..8b19f95 --- /dev/null +++ b/tests/semantics/test_control_time_scheduling_semantics.py @@ -0,0 +1,202 @@ +"""Control scheduling obligation semantics: rate-limit vs inflight deferral. + +See ``docs/flows/control-time-and-scheduling.md`` for the normative description. +""" + +from __future__ import annotations + +import tradingchassis_core as tc + + +class _AllowAllPolicy: + def evaluate_policy_intent( + self, + *, + intent: tc.OrderIntent, + state: tc.StrategyState, + now_ts_ns_local: int, + ) -> tuple[bool, str | None]: + _ = (intent, state, now_ts_ns_local) + return True, None + + +def _control_entry(index: int, ts: int) -> tc.EventStreamEntry: + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=tc.ControlTimeEvent( + ts_ns_local_control=ts, + reason="scheduled_control_recheck", + due_ts_ns_local=ts, + realized_ts_ns_local=ts, + obligation_reason="rate_limit", + obligation_due_ts_ns_local=ts, + runtime_correlation=None, + ), + ) + + +def _order_submitted_entry( + index: int, + ts_dispatch: int, + *, + client_order_id: str = "order-a", + price: float = 100.0, +) -> tc.EventStreamEntry: + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=tc.OrderSubmittedEvent( + ts_ns_local_dispatch=ts_dispatch, + instrument="BTC-USDC-PERP", + client_order_id=client_order_id, + side="buy", + order_type="limit", + intended_price=tc.Price(currency="USDC", value=price), + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + time_in_force="GTC", + intent_correlation_id=None, + dispatch_attempt_id=None, + runtime_correlation=None, + ), + ) + + +class _NewIntentEvaluator: + def evaluate(self, context: object) -> list[tc.NewOrderIntent]: + _ = context + return [ + tc.NewOrderIntent( + intent_type="new", + ts_ns_local=10, + instrument="BTC-USDC-PERP", + client_order_id="intent-1", + intents_correlation_id="corr-1", + side="buy", + order_type="limit", + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + ] + + +class _ReplaceIntentEvaluator: + """Emits a single replace against ``order-a`` (requires a working order).""" + + def evaluate(self, context: object) -> list[tc.ReplaceOrderIntent]: + _ = context + return [ + tc.ReplaceOrderIntent( + intent_type="replace", + ts_ns_local=100, + instrument="BTC-USDC-PERP", + client_order_id="order-a", + intents_correlation_id="corr-repl", + side="buy", + order_type="limit", + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=99.0), + ) + ] + + +def _policy_and_apply( + *, + now_ts: int, + max_orders_per_sec: float | None = None, +) -> tuple[tc.CorePolicyAdmissionContext, tc.CoreExecutionControlApplyContext]: + return ( + tc.CorePolicyAdmissionContext( + policy_evaluator=_AllowAllPolicy(), + now_ts_ns_local=now_ts, + ), + tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=now_ts, + max_orders_per_sec=max_orders_per_sec, + activate_dispatchable_outputs=True, + ), + ) + + +def test_rate_limit_deferral_emits_control_scheduling_obligation() -> None: + """Time-dependent rate limiting produces a non-canonical scheduling obligation.""" + now_ts = 100 + state = tc.StrategyState(event_bus=tc.NullEventBus()) + policy_ctx, apply_ctx = _policy_and_apply(now_ts=now_ts, max_orders_per_sec=0.0) + + result = tc.run_core_step( + state, + _control_entry(0, now_ts), + strategy_evaluator=_NewIntentEvaluator(), + policy_admission_context=policy_ctx, + execution_control_apply_context=apply_ctx, + ) + + assert result.dispatchable_intents == () + obl = result.control_scheduling_obligation + assert obl is not None + assert obl.reason == "rate_limit" + assert obl.source == "execution_control_rate_limit" + assert obl.due_ts_ns_local >= now_ts + assert obl.scope_key == "instrument:BTC-USDC-PERP" + + +def test_inflight_deferral_does_not_emit_control_scheduling_obligation() -> None: + """Inflight gating is feedback-dependent; Core does not emit a wake obligation.""" + now_ts = 100 + state = tc.StrategyState(event_bus=tc.NullEventBus()) + tc.process_event_entry(state, _order_submitted_entry(0, now_ts)) + assert state.has_working_order("BTC-USDC-PERP", "order-a") + + state.mark_intent_sent("BTC-USDC-PERP", "order-a", "replace") + assert state.has_inflight("BTC-USDC-PERP", "order-a") + + policy_ctx, apply_ctx = _policy_and_apply(now_ts=now_ts, max_orders_per_sec=None) + + result = tc.run_core_step( + state, + _control_entry(1, now_ts), + strategy_evaluator=_ReplaceIntentEvaluator(), + policy_admission_context=policy_ctx, + execution_control_apply_context=apply_ctx, + ) + + assert result.control_scheduling_obligation is None + assert result.dispatchable_intents == () + assert result.core_step_decision is not None + assert len(result.core_step_decision.queued_effective_intents) >= 1 + queued = state.queued_intents.get("BTC-USDC-PERP") + assert queued is not None and len(queued) == 1 + assert queued[0].intent.intent_type == "replace" + + +def test_inflight_queued_replace_reprocessed_after_order_submitted_feedback() -> None: + """Canonical OrderSubmittedEvent clears inflight so a later step can dispatch Queue.""" + now_ts = 100 + state = tc.StrategyState(event_bus=tc.NullEventBus()) + tc.process_event_entry(state, _order_submitted_entry(0, now_ts)) + state.mark_intent_sent("BTC-USDC-PERP", "order-a", "replace") + + policy_ctx, apply_ctx = _policy_and_apply(now_ts=now_ts, max_orders_per_sec=None) + blocked = tc.run_core_step( + state, + _control_entry(1, now_ts), + strategy_evaluator=_ReplaceIntentEvaluator(), + policy_admission_context=policy_ctx, + execution_control_apply_context=apply_ctx, + ) + assert blocked.dispatchable_intents == () + assert blocked.control_scheduling_obligation is None + + policy_ctx2, apply_ctx2 = _policy_and_apply(now_ts=now_ts + 1, max_orders_per_sec=None) + cleared = tc.run_core_step( + state, + _order_submitted_entry(2, now_ts + 1, price=99.0), + policy_admission_context=policy_ctx2, + execution_control_apply_context=apply_ctx2, + ) + + assert not state.has_inflight("BTC-USDC-PERP", "order-a") + assert len(cleared.dispatchable_intents) == 1 + assert cleared.dispatchable_intents[0].intent_type == "replace" + assert cleared.control_scheduling_obligation is None diff --git a/tests/semantics/test_core_pipeline_clean.py b/tests/semantics/test_core_pipeline_clean.py new file mode 100644 index 0000000..7daae42 --- /dev/null +++ b/tests/semantics/test_core_pipeline_clean.py @@ -0,0 +1,264 @@ +"""Clean CoreStep/CoreWakeupStep pipeline tests.""" + +from __future__ import annotations + +import tradingchassis_core as tc +from tradingchassis_core.core.domain.types import BookLevel, BookPayload + + +class _OneIntentEvaluator: + def evaluate(self, context: object) -> list[tc.NewOrderIntent]: + _ = context + return [ + tc.NewOrderIntent( + intent_type="new", + ts_ns_local=10, + instrument="BTC-USDC-PERP", + client_order_id="intent-1", + intents_correlation_id="corr-1", + side="buy", + order_type="limit", + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + ] + + +class _AllowAllPolicy: + def evaluate_policy_intent( + self, + *, + intent: tc.OrderIntent, + state: tc.StrategyState, + now_ts_ns_local: int, + ) -> tuple[bool, str | None]: + _ = (intent, state, now_ts_ns_local) + return True, None + + +class _RejectAllPolicy: + def evaluate_policy_intent( + self, + *, + intent: tc.OrderIntent, + state: tc.StrategyState, + now_ts_ns_local: int, + ) -> tuple[bool, str | None]: + _ = (intent, state, now_ts_ns_local) + return False, "blocked_for_test" + + +class _DuplicateIntentEvaluator: + def evaluate(self, context: object) -> list[tc.NewOrderIntent]: + _ = context + first = tc.NewOrderIntent( + intent_type="new", + ts_ns_local=10, + instrument="BTC-USDC-PERP", + client_order_id="dup-intent", + intents_correlation_id="corr-a", + side="buy", + order_type="limit", + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + second = tc.NewOrderIntent( + intent_type="new", + ts_ns_local=11, + instrument="BTC-USDC-PERP", + client_order_id="dup-intent", + intents_correlation_id="corr-b", + side="buy", + order_type="limit", + intended_qty=tc.Quantity(value=2.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=101.0), + time_in_force="GTC", + ) + return [first, second] + + +def _control_entry(index: int, ts: int) -> tc.EventStreamEntry: + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=tc.ControlTimeEvent( + ts_ns_local_control=ts, + reason="scheduled_control_recheck", + due_ts_ns_local=ts, + realized_ts_ns_local=ts, + obligation_reason="rate_limit", + obligation_due_ts_ns_local=ts, + runtime_correlation=None, + ), + ) + + +def test_run_core_step_clean_pipeline_dispatchable() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + result = tc.run_core_step( + state, + _control_entry(0, 100), + strategy_evaluator=_OneIntentEvaluator(), + policy_admission_context=tc.CorePolicyAdmissionContext( + policy_evaluator=_AllowAllPolicy(), + now_ts_ns_local=100, + ), + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=100, + activate_dispatchable_outputs=True, + ), + ) + assert tuple(intent.client_order_id for intent in result.generated_intents) == ("intent-1",) + assert tuple(intent.client_order_id for intent in result.candidate_intents) == ("intent-1",) + assert tuple(record.origin for record in result.candidate_intent_records) == ( + tc.CandidateIntentOrigin.GENERATED, + ) + assert tuple(intent.client_order_id for intent in result.dispatchable_intents) == ("intent-1",) + assert result.core_step_decision is not None + + +def test_run_core_step_processes_entry_before_strategy_evaluation() -> None: + class _ChecksReducedStateEvaluator: + def evaluate(self, context: tc.CoreStepStrategyContext) -> list[tc.OrderIntent]: + # ControlTimeEvent reduction updates monotone timestamp before evaluation. + assert context.state.sim_ts_ns_local == 100 + assert isinstance(context.event, tc.ControlTimeEvent) + assert context.position.index == 0 + return [] + + state = tc.StrategyState(event_bus=tc.NullEventBus()) + _ = tc.run_core_step( + state, + _control_entry(0, 100), + strategy_evaluator=_ChecksReducedStateEvaluator(), + ) + assert state._last_processing_position_index == 0 + + +def test_run_core_wakeup_step_clean_pipeline_dispatchable() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + result = tc.run_core_wakeup_step( + state, + (_control_entry(0, 100), _control_entry(1, 101)), + wakeup_strategy_evaluator=_OneIntentEvaluator(), + policy_admission_context=tc.CorePolicyAdmissionContext( + policy_evaluator=_AllowAllPolicy(), + now_ts_ns_local=101, + ), + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=101, + activate_dispatchable_outputs=True, + ), + ) + assert len(result.generated_intents) == 1 + assert len(result.candidate_intent_records) == 1 + assert len(result.dispatchable_intents) == 1 + + +def test_candidate_reconciliation_prefers_latest_same_key_generated_intent() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + result = tc.run_core_step( + state, + _control_entry(0, 100), + strategy_evaluator=_DuplicateIntentEvaluator(), + ) + assert len(result.generated_intents) == 2 + assert len(result.candidate_intent_records) == 1 + winner = result.candidate_intent_records[0].intent + assert isinstance(winner, tc.NewOrderIntent) + assert winner.client_order_id == "dup-intent" + assert winner.intended_qty.value == 2.0 + + +def test_policy_rejection_prevents_dispatchable_intents() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + result = tc.run_core_step( + state, + _control_entry(0, 100), + strategy_evaluator=_OneIntentEvaluator(), + policy_admission_context=tc.CorePolicyAdmissionContext( + policy_evaluator=_RejectAllPolicy(), + now_ts_ns_local=100, + ), + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=100, + activate_dispatchable_outputs=True, + ), + ) + assert result.dispatchable_intents == () + assert result.core_step_decision is not None + assert len(result.core_step_decision.policy_rejected_intents) == 1 + assert result.core_step_decision.policy_risk_decision is not None + assert len(result.core_step_decision.policy_risk_decision.accepted_intents) == 0 + assert len(result.core_step_decision.policy_risk_decision.rejected_intents) == 1 + + +def test_execution_control_deferral_returns_scheduling_obligation() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + result = tc.run_core_step( + state, + _control_entry(0, 100), + strategy_evaluator=_OneIntentEvaluator(), + policy_admission_context=tc.CorePolicyAdmissionContext( + policy_evaluator=_AllowAllPolicy(), + now_ts_ns_local=100, + ), + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=100, + max_orders_per_sec=0.0, + activate_dispatchable_outputs=True, + ), + ) + assert result.dispatchable_intents == () + assert result.control_scheduling_obligation is not None + assert result.control_scheduling_obligation.reason == "rate_limit" + + +def test_core_step_generated_only_mode_never_dispatches_externally() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + result = tc.run_core_step( + state, + _control_entry(0, 100), + strategy_evaluator=_OneIntentEvaluator(), + ) + assert tuple(intent.client_order_id for intent in result.generated_intents) == ("intent-1",) + assert result.core_step_decision is None + assert result.dispatchable_intents == () + + +def test_process_canonical_event_reduces_market_event() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + tc.process_canonical_event( + state, + tc.MarketEvent( + ts_ns_exch=200, + ts_ns_local=201, + instrument="BTC-USDC-PERP", + event_type="book", + book=BookPayload( + book_type="snapshot", + bids=[ + BookLevel( + price=tc.Price(currency="USDC", value=99.0), + quantity=tc.Quantity(value=1.0, unit="contracts"), + ) + ], + asks=[ + BookLevel( + price=tc.Price(currency="USDC", value=101.0), + quantity=tc.Quantity(value=2.0, unit="contracts"), + ) + ], + depth=1, + ), + ), + ) + assert state.sim_ts_ns_local == 201 + market = state.market["BTC-USDC-PERP"] + assert market.best_bid == 99.0 + assert market.best_ask == 101.0 diff --git a/tests/semantics/test_core_wakeup_final_state.py b/tests/semantics/test_core_wakeup_final_state.py new file mode 100644 index 0000000..67b1dab --- /dev/null +++ b/tests/semantics/test_core_wakeup_final_state.py @@ -0,0 +1,298 @@ +"""Final-state CoreWakeupStep Strategy evaluation semantics (Phase WU2).""" + +from __future__ import annotations + +import tradingchassis_core as tc +from tradingchassis_core.core.domain.types import BookLevel, BookPayload + +INSTRUMENT = "BTC-USDC-PERP" + +_TEST_CONFIGURATION = tc.CoreConfiguration( + version="test-v1", + payload={ + "market": { + "instruments": { + INSTRUMENT: { + "tick_size": 0.01, + "lot_size": 0.001, + "contract_size": 1.0, + } + } + } + }, +) + + + +class _OneIntentEvaluator: + def evaluate(self, context: tc.CoreWakeupStrategyContext) -> list[tc.NewOrderIntent]: + _ = context + return [ + tc.NewOrderIntent( + intent_type="new", + ts_ns_local=10, + instrument=INSTRUMENT, + client_order_id="wake-generated", + intents_correlation_id="corr-wake", + side="buy", + order_type="limit", + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + ] + + +class _CountingWakeupEvaluator: + def __init__(self) -> None: + self.call_count = 0 + self.last_context: tc.CoreWakeupStrategyContext | None = None + + def evaluate(self, context: tc.CoreWakeupStrategyContext) -> list[tc.OrderIntent]: + self.call_count += 1 + self.last_context = context + return [] + + +class _FinalStateAwareEvaluator: + def evaluate(self, context: tc.CoreWakeupStrategyContext) -> list[tc.OrderIntent]: + assert context.state.sim_ts_ns_local == 203 + market = context.state.market[INSTRUMENT] + assert market.best_bid == 99.0 + assert market.best_ask == 101.0 + account = context.state.account[INSTRUMENT] + assert account.position == 2.5 + assert len(context.entries) == 3 + assert isinstance(context.entries[0].event, tc.OrderExecutionFeedbackEvent) + assert isinstance(context.entries[1].event, tc.MarketEvent) + assert isinstance(context.entries[2].event, tc.ControlTimeEvent) + assert context.last_position is not None + assert context.last_position.index == 2 + return [] + + +class _AllowAllPolicy: + def evaluate_policy_intent( + self, + *, + intent: tc.OrderIntent, + state: tc.StrategyState, + now_ts_ns_local: int, + ) -> tuple[bool, str | None]: + _ = (intent, state, now_ts_ns_local) + return True, None + + +def _control_entry(index: int, ts: int) -> tc.EventStreamEntry: + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=tc.ControlTimeEvent( + ts_ns_local_control=ts, + reason="scheduled_control_recheck", + due_ts_ns_local=ts, + realized_ts_ns_local=ts, + obligation_reason="rate_limit", + obligation_due_ts_ns_local=ts, + runtime_correlation=None, + ), + ) + + +def _market_entry(index: int, ts: int) -> tc.EventStreamEntry: + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=tc.MarketEvent( + ts_ns_exch=ts - 1, + ts_ns_local=ts, + instrument=INSTRUMENT, + event_type="book", + book=BookPayload( + book_type="snapshot", + bids=[ + BookLevel( + price=tc.Price(currency="USDC", value=99.0), + quantity=tc.Quantity(value=1.0, unit="contracts"), + ) + ], + asks=[ + BookLevel( + price=tc.Price(currency="USDC", value=101.0), + quantity=tc.Quantity(value=2.0, unit="contracts"), + ) + ], + depth=1, + ), + ), + ) + + +def _execution_feedback_entry(index: int, ts: int) -> tc.EventStreamEntry: + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=tc.OrderExecutionFeedbackEvent( + ts_ns_local_feedback=ts, + instrument=INSTRUMENT, + position=2.5, + balance=10_000.0, + fee=0.1, + trading_volume=1.0, + trading_value=100.0, + num_trades=1, + runtime_correlation=None, + ), + ) + + +def _queued_intent(client_order_id: str) -> tc.NewOrderIntent: + return tc.NewOrderIntent( + intent_type="new", + ts_ns_local=50, + instrument=INSTRUMENT, + client_order_id=client_order_id, + intents_correlation_id="queued-corr", + side="sell", + order_type="limit", + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=99.5), + time_in_force="GTC", + ) + + +def test_wakeup_reduces_all_entries_before_strategy_evaluation() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + entries = ( + _execution_feedback_entry(0, 200), + _market_entry(1, 201), + _control_entry(2, 203), + ) + _ = tc.run_core_wakeup_step( + state, + entries, + configuration=_TEST_CONFIGURATION, + wakeup_strategy_evaluator=_FinalStateAwareEvaluator(), + ) + + +def test_wakeup_strategy_evaluator_called_exactly_once() -> None: + entries = (_control_entry(0, 100), _control_entry(1, 101)) + state = tc.StrategyState(event_bus=tc.NullEventBus()) + evaluator = _CountingWakeupEvaluator() + _ = tc.run_core_wakeup_step(state, entries, wakeup_strategy_evaluator=evaluator) + assert evaluator.call_count == 1 + assert evaluator.last_context is not None + assert evaluator.last_context.entries == entries + assert evaluator.last_context.state.sim_ts_ns_local == 101 + assert evaluator.last_context.last_position == entries[-1].position + + +def test_wakeup_generated_intents_combined_once_with_queued_intents() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + state.merge_intents_into_queue(INSTRUMENT, [_queued_intent("queued-1")]) + result = tc.run_core_wakeup_step( + state, + (_control_entry(0, 100),), + wakeup_strategy_evaluator=_OneIntentEvaluator(), + ) + assert tuple(intent.client_order_id for intent in result.generated_intents) == ( + "wake-generated", + ) + origins = tuple(record.origin for record in result.candidate_intent_records) + assert tc.CandidateIntentOrigin.GENERATED in origins + assert tc.CandidateIntentOrigin.QUEUED in origins + client_ids = {record.intent.client_order_id for record in result.candidate_intent_records} + assert client_ids == {"wake-generated", "queued-1"} + + +def test_wakeup_policy_and_execution_control_apply_once() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + result = tc.run_core_wakeup_step( + state, + (_control_entry(0, 100), _control_entry(1, 101)), + wakeup_strategy_evaluator=_OneIntentEvaluator(), + policy_admission_context=tc.CorePolicyAdmissionContext( + policy_evaluator=_AllowAllPolicy(), + now_ts_ns_local=101, + ), + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=101, + activate_dispatchable_outputs=True, + ), + ) + assert len(result.generated_intents) == 1 + assert len(result.dispatchable_intents) == 1 + assert result.core_step_decision is not None + policy_decision = result.core_step_decision.policy_risk_decision + assert policy_decision is not None + assert len(policy_decision.accepted_intents) == 1 + + +def test_empty_wakeup_batch_is_valid_without_evaluator() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + reduction = tc.run_core_wakeup_reduction(state, ()) + assert reduction.entries == () + assert reduction.generated_intents == () + result = tc.run_core_wakeup_decision(state, reduction) + assert result.generated_intents == () + assert result.candidate_intent_records == () + + +def test_empty_wakeup_batch_with_evaluator_runs_once() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + evaluator = _CountingWakeupEvaluator() + reduction = tc.run_core_wakeup_reduction(state, (), wakeup_strategy_evaluator=evaluator) + assert evaluator.call_count == 1 + assert reduction.generated_intents == () + assert evaluator.last_context is not None + assert evaluator.last_context.entries == () + assert evaluator.last_context.last_position is None + + +def test_single_entry_wakeup_evaluates_once() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + evaluator = _CountingWakeupEvaluator() + _ = tc.run_core_wakeup_step(state, (_control_entry(0, 100),), wakeup_strategy_evaluator=evaluator) + assert evaluator.call_count == 1 + + +def test_run_core_wakeup_step_matches_reduction_then_decision_path() -> None: + entries = (_control_entry(0, 100), _control_entry(1, 101)) + reduction_state = tc.StrategyState(event_bus=tc.NullEventBus()) + reduction = tc.run_core_wakeup_reduction( + reduction_state, + entries, + wakeup_strategy_evaluator=_OneIntentEvaluator(), + ) + decision_result = tc.run_core_wakeup_decision( + reduction_state, + reduction, + policy_admission_context=tc.CorePolicyAdmissionContext( + policy_evaluator=_AllowAllPolicy(), + now_ts_ns_local=101, + ), + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=101, + activate_dispatchable_outputs=True, + ), + ) + + step_state = tc.StrategyState(event_bus=tc.NullEventBus()) + step_result = tc.run_core_wakeup_step( + step_state, + entries, + wakeup_strategy_evaluator=_OneIntentEvaluator(), + policy_admission_context=tc.CorePolicyAdmissionContext( + policy_evaluator=_AllowAllPolicy(), + now_ts_ns_local=101, + ), + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=101, + activate_dispatchable_outputs=True, + ), + ) + + assert decision_result == step_result + assert len(step_result.generated_intents) == 1 + assert len(step_result.dispatchable_intents) == 1 diff --git a/tests/semantics/test_public_api_clean.py b/tests/semantics/test_public_api_clean.py new file mode 100644 index 0000000..6eecc4b --- /dev/null +++ b/tests/semantics/test_public_api_clean.py @@ -0,0 +1,63 @@ +"""Public API surface checks for clean Core exports.""" + +from __future__ import annotations + +import tradingchassis_core as tc + + +def test_public_api_exposes_clean_core_symbols() -> None: + for symbol in ( + "EventStreamEntry", + "ProcessingPosition", + "process_canonical_event", + "process_event_entry", + "run_core_step", + "run_core_wakeup_reduction", + "run_core_wakeup_decision", + "run_core_wakeup_step", + "CoreWakeupStrategyContext", + "CoreWakeupStrategyEvaluator", + "CoreStepResult", + "CoreStepDecision", + "PolicyRiskDecision", + "ExecutionControlDecision", + "CandidateIntentRecord", + "CandidateIntentOrigin", + "CorePolicyAdmissionContext", + "CoreExecutionControlApplyContext", + "ControlTimeEvent", + "MarketEvent", + "OrderSubmittedEvent", + "OrderExecutionFeedbackEvent", + "FillEvent", + "OrderIntent", + "NewOrderIntent", + "CancelOrderIntent", + "ReplaceOrderIntent", + "Price", + "Quantity", + "CoreConfiguration", + "StrategyState", + "ExecutionControl", + "ControlSchedulingObligation", + "NullEventBus", + "RiskEngine", + "RiskConfig", + ): + assert hasattr(tc, symbol), symbol + + +def test_public_api_does_not_expose_removed_compatibility_symbols() -> None: + removed = ( + "".join(["Gate", "Decision"]), + "".join(["compat_", "gate_decision"]), + "".join(["ControlTimeQueue", "ReevaluationContext"]), + "".join(["Core", "DecisionContext"]), + "".join(["OrderState", "Event"]), + "".join(["Derived", "FillEvent"]), + "".join(["decide_", "intents"]), + "".join(["Venue", "Adapter"]), + "".join(["Venue", "Policy"]), + ) + for symbol in removed: + assert not hasattr(tc, symbol) diff --git a/tests/semantics/test_risk_engine_policy_only.py b/tests/semantics/test_risk_engine_policy_only.py new file mode 100644 index 0000000..9fd7b98 --- /dev/null +++ b/tests/semantics/test_risk_engine_policy_only.py @@ -0,0 +1,61 @@ +"""RiskEngine policy-only behavior tests.""" + +from __future__ import annotations + +import tradingchassis_core as tc +from tradingchassis_core.core.domain.types import NotionalLimits + + +def _risk_config() -> tc.RiskConfig: + return tc.RiskConfig( + scope="test", + trading_enabled=True, + notional_limits=NotionalLimits( + currency="USDC", + max_gross_notional=1_000_000.0, + max_single_order_notional=1_000_000.0, + ), + position_limits=None, + quote_limits=None, + order_rate_limits=None, + max_loss=None, + ) + + +def _intent() -> tc.NewOrderIntent: + return tc.NewOrderIntent( + intent_type="new", + ts_ns_local=10, + instrument="BTC-USDC-PERP", + client_order_id="risk-intent-1", + intents_correlation_id="corr-1", + side="buy", + order_type="limit", + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + + +def test_risk_engine_evaluate_policy_intent_accepts_valid_intent() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + state.update_market( + instrument="BTC-USDC-PERP", + best_bid=99.0, + best_ask=101.0, + best_bid_qty=1.0, + best_ask_qty=1.0, + tick_size=0.1, + lot_size=0.01, + contract_size=1.0, + ts_ns_local=10, + ts_ns_exch=9, + ) + engine = tc.RiskEngine(_risk_config()) + accepted, reason = engine.evaluate_policy_intent( + intent=_intent(), + state=state, + now_ts_ns_local=10, + ) + assert accepted is True + assert reason is None diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index e4b0063..4f5f241 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -1,102 +1,136 @@ -"""Public API for the tradingchassis_core package. - -Only symbols imported here are considered part of the stable, -supported external interface. -""" +"""Public API for the tradingchassis_core package.""" from __future__ import annotations from importlib.metadata import PackageNotFoundError, version +from tradingchassis_core.core.domain.candidate_intent import ( + CandidateIntentOrigin, + CandidateIntentRecord, +) from tradingchassis_core.core.domain.configuration import CoreConfiguration +from tradingchassis_core.core.domain.execution_control_apply import ( + ExecutionControlApplyContext, + ExecutionControlApplyResult, + ExecutionControlBlockedRecord, + ExecutionControlDispatchableRecord, + ExecutionControlHandledRecord, + apply_execution_control_plan, +) +from tradingchassis_core.core.domain.execution_control_decision import ( + ExecutionControlDecision, +) +from tradingchassis_core.core.domain.policy_risk_decision import ( + PolicyAdmissionResult, + PolicyRejectedCandidate, + PolicyRiskDecision, +) from tradingchassis_core.core.domain.processing import ( fold_event_stream_entries, + process_canonical_event, process_event_entry, ) from tradingchassis_core.core.domain.processing_order import ( EventStreamEntry, ProcessingPosition, ) - -# ---------------------------------------------------------------------- -# Backtest Engine API -# ---------------------------------------------------------------------- -# -# Backtest engine/runtime code is runtime-owned and has moved to the -# Core Runtime repository (import from `core_runtime.backtest.*`). -# -# This semantic-core package must remain importable without the runtime layer. +from tradingchassis_core.core.domain.processing_step import ( + CoreExecutionControlApplyContext, + CorePolicyAdmissionContext, + CoreStepStrategyContext, + CoreStepStrategyEvaluator, + CoreWakeupReductionResult, + CoreWakeupStrategyContext, + CoreWakeupStrategyEvaluator, + run_core_step, + run_core_wakeup_decision, + run_core_wakeup_reduction, + run_core_wakeup_step, +) from tradingchassis_core.core.domain.slots import ( SlotKey, stable_slot_order_id, ) - -# ---------------------------------------------------------------------- -# Domain Types (used by strategies) -# ---------------------------------------------------------------------- from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.step_decision import CoreStepDecision +from tradingchassis_core.core.domain.step_result import CoreStepResult from tradingchassis_core.core.domain.types import ( + CancelOrderIntent, + ControlTimeEvent, + FillEvent, MarketEvent, NewOrderIntent, + NotionalLimits, + OrderExecutionFeedbackEvent, OrderIntent, + OrderSubmittedEvent, Price, Quantity, ReplaceOrderIntent, RiskConstraints, ) -from tradingchassis_core.core.ports.engine_context import EngineContext - -# ---------------------------------------------------------------------- -# Config API (used by consumers) -# ---------------------------------------------------------------------- +from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus +from tradingchassis_core.core.execution_control.execution_control import ExecutionControl +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import GateDecision - -# ---------------------------------------------------------------------- -# Strategy Interface -# ---------------------------------------------------------------------- -from tradingchassis_core.strategies.base import Strategy -from tradingchassis_core.strategies.strategy_config import StrategyConfig - -# ---------------------------------------------------------------------- -# Public API definition -# ---------------------------------------------------------------------- +from tradingchassis_core.core.risk.risk_engine import RiskEngine __all__ = [ - # Config + "CoreConfiguration", "RiskConfig", - "StrategyConfig", - - # Strategy interface - "Strategy", - - # Strategy-facing domain API + "RiskEngine", "StrategyState", "MarketEvent", + "ControlTimeEvent", + "OrderSubmittedEvent", + "OrderExecutionFeedbackEvent", + "FillEvent", "RiskConstraints", + "NotionalLimits", "OrderIntent", "NewOrderIntent", + "CancelOrderIntent", "ReplaceOrderIntent", "Price", "Quantity", "SlotKey", "stable_slot_order_id", - "EngineContext", - "GateDecision", - "CoreConfiguration", + "CandidateIntentOrigin", + "CandidateIntentRecord", "ProcessingPosition", "EventStreamEntry", + "process_canonical_event", "process_event_entry", "fold_event_stream_entries", - - # Version + "run_core_step", + "run_core_wakeup_reduction", + "run_core_wakeup_decision", + "run_core_wakeup_step", + "CoreStepStrategyContext", + "CoreStepStrategyEvaluator", + "CoreWakeupStrategyContext", + "CoreWakeupStrategyEvaluator", + "CoreExecutionControlApplyContext", + "CorePolicyAdmissionContext", + "CoreWakeupReductionResult", + "ExecutionControlDecision", + "ExecutionControlApplyContext", + "ExecutionControlApplyResult", + "ExecutionControlBlockedRecord", + "ExecutionControlDispatchableRecord", + "ExecutionControlHandledRecord", + "apply_execution_control_plan", + "PolicyRiskDecision", + "PolicyRejectedCandidate", + "PolicyAdmissionResult", + "CoreStepDecision", + "CoreStepResult", + "ExecutionControl", + "ControlSchedulingObligation", + "NullEventBus", "__version__", ] -# ---------------------------------------------------------------------- -# Package version -# ---------------------------------------------------------------------- - try: __version__ = version("tradingchassis-core") except PackageNotFoundError: diff --git a/tradingchassis_core/core/domain/__init__.py b/tradingchassis_core/core/domain/__init__.py index e69de29..9fbf55e 100644 --- a/tradingchassis_core/core/domain/__init__.py +++ b/tradingchassis_core/core/domain/__init__.py @@ -0,0 +1,55 @@ +"""Public exports for core domain value objects.""" + +from tradingchassis_core.core.domain.candidate_intent import ( + CandidateIntentOrigin, + CandidateIntentRecord, +) +from tradingchassis_core.core.domain.execution_control_apply import ( + ExecutionControlApplyContext, + ExecutionControlApplyResult, + ExecutionControlBlockedRecord, + ExecutionControlDispatchableRecord, + ExecutionControlHandledRecord, + apply_execution_control_plan, +) +from tradingchassis_core.core.domain.execution_control_decision import ExecutionControlDecision +from tradingchassis_core.core.domain.policy_risk_decision import ( + PolicyAdmissionResult, + PolicyRejectedCandidate, + PolicyRiskDecision, +) +from tradingchassis_core.core.domain.processing_step import ( + CoreExecutionControlApplyContext, + CorePolicyAdmissionContext, + CoreWakeupReductionResult, + run_core_step, + run_core_wakeup_decision, + run_core_wakeup_reduction, + run_core_wakeup_step, +) +from tradingchassis_core.core.domain.step_decision import CoreStepDecision +from tradingchassis_core.core.domain.step_result import CoreStepResult + +__all__ = [ + "CandidateIntentOrigin", + "CandidateIntentRecord", + "ExecutionControlDecision", + "ExecutionControlApplyContext", + "ExecutionControlApplyResult", + "ExecutionControlBlockedRecord", + "ExecutionControlDispatchableRecord", + "ExecutionControlHandledRecord", + "apply_execution_control_plan", + "PolicyRiskDecision", + "PolicyRejectedCandidate", + "PolicyAdmissionResult", + "CoreStepDecision", + "CoreStepResult", + "CoreExecutionControlApplyContext", + "CorePolicyAdmissionContext", + "CoreWakeupReductionResult", + "run_core_wakeup_reduction", + "run_core_wakeup_decision", + "run_core_wakeup_step", + "run_core_step", +] diff --git a/tradingchassis_core/core/domain/candidate_intent.py b/tradingchassis_core/core/domain/candidate_intent.py new file mode 100644 index 0000000..2fba7c8 --- /dev/null +++ b/tradingchassis_core/core/domain/candidate_intent.py @@ -0,0 +1,26 @@ +"""Core-owned non-canonical candidate intent provenance models.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from tradingchassis_core.core.domain.types import OrderIntent + + +class CandidateIntentOrigin(str, Enum): + """Origin marker for candidate intents in one Core step.""" + + GENERATED = "generated" + QUEUED = "queued" + + +@dataclass(frozen=True, slots=True) +class CandidateIntentRecord: + """Non-canonical Core-step candidate record with explicit provenance.""" + + intent: OrderIntent + origin: CandidateIntentOrigin + logical_key: str + merge_index: int + priority: int diff --git a/tradingchassis_core/core/domain/event_model.py b/tradingchassis_core/core/domain/event_model.py index 126233c..9f51443 100644 --- a/tradingchassis_core/core/domain/event_model.py +++ b/tradingchassis_core/core/domain/event_model.py @@ -1,12 +1,4 @@ -"""Docs-aligned event taxonomy markers for core. - -This module is intentionally lightweight. It defines semantic markers used to -disambiguate canonical Event Stream candidates from non-canonical artifacts in -the current core codebase. - -It does not implement Event Stream append semantics, Processing Order, replay, -or transport behavior. -""" +"""Canonical Event taxonomy markers for core.""" from __future__ import annotations @@ -16,11 +8,10 @@ ControlTimeEvent, FillEvent, MarketEvent, - OrderStateEvent, + OrderExecutionFeedbackEvent, OrderSubmittedEvent, ) from tradingchassis_core.core.events.events import ( - DerivedFillEvent, DerivedPnLEvent, ExposureDerivedEvent, OrderStateTransitionEvent, @@ -30,7 +21,7 @@ class CanonicalEventCategory(str, Enum): - """Canonical Event Stream categories from docs.""" + """Canonical Event Stream categories.""" MARKET = "market" INTENT_RELATED = "intent_related" @@ -42,19 +33,14 @@ class CanonicalEventCategory(str, Enum): category.value for category in CanonicalEventCategory ) - -# Canonical Event Stream candidates recognized in this slice. -# Note: FillEvent is tracked as a canonical execution-event candidate, but -# candidate status does not imply it is newly wired into runtime flow. CANONICAL_STREAM_CANDIDATE_CATEGORY_BY_TYPE: dict[type[object], CanonicalEventCategory] = { MarketEvent: CanonicalEventCategory.MARKET, OrderSubmittedEvent: CanonicalEventCategory.INTENT_RELATED, FillEvent: CanonicalEventCategory.EXECUTION, + OrderExecutionFeedbackEvent: CanonicalEventCategory.EXECUTION, ControlTimeEvent: CanonicalEventCategory.CONTROL, } - -# Non-canonical telemetry / observability records. TELEMETRY_EVENT_TYPES: frozenset[type[object]] = frozenset( { RiskDecisionEvent, @@ -64,17 +50,6 @@ class CanonicalEventCategory(str, Enum): } ) - -# Compatibility projection records (kept for current snapshot-driven flow). -COMPATIBILITY_PROJECTION_TYPES: frozenset[type[object]] = frozenset( - { - OrderStateEvent, - DerivedFillEvent, - } -) - - -# Non-canonical runtime-facing control helper. This is intentionally not an Event. NON_CANONICAL_CONTROL_HELPER_TYPES: frozenset[type[object]] = frozenset( {ControlSchedulingObligation} ) @@ -82,12 +57,9 @@ class CanonicalEventCategory(str, Enum): def canonical_category_for_type(record_type: type[object]) -> CanonicalEventCategory | None: """Return canonical category for recognized canonical stream candidates.""" - return CANONICAL_STREAM_CANDIDATE_CATEGORY_BY_TYPE.get(record_type) def is_canonical_stream_candidate_type(record_type: type[object]) -> bool: """Return True when the type is marked as a canonical Event candidate.""" - return record_type in CANONICAL_STREAM_CANDIDATE_CATEGORY_BY_TYPE - diff --git a/tradingchassis_core/core/domain/execution_control_apply.py b/tradingchassis_core/core/domain/execution_control_apply.py new file mode 100644 index 0000000..ff614b0 --- /dev/null +++ b/tradingchassis_core/core/domain/execution_control_apply.py @@ -0,0 +1,336 @@ +"""Mutable Execution Control apply stage over a pure Execution Control plan.""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass, field + +from tradingchassis_core.core.domain.candidate_intent import ( + CandidateIntentOrigin, + CandidateIntentRecord, +) +from tradingchassis_core.core.domain.execution_control_decision import ( + ExecutionControlDecision, +) +from tradingchassis_core.core.domain.execution_control_plan import ( + ExecutionControlPlan, +) +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.execution_control.execution_control import ExecutionControl +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation + + +@dataclass(frozen=True, slots=True) +class ExecutionControlApplyContext: + """Mutable apply inputs for one deterministic apply operation.""" + + state: StrategyState + execution_control: ExecutionControl + now_ts_ns_local: int + max_orders_per_sec: float | None = None + max_cancels_per_sec: float | None = None + + +@dataclass(frozen=True, slots=True) +class ExecutionControlDispatchableRecord: + """Candidate record selected as dispatchable in this apply pass.""" + + record: CandidateIntentRecord + + +@dataclass(frozen=True, slots=True) +class ExecutionControlBlockedRecord: + """Candidate record blocked from immediate dispatch.""" + + record: CandidateIntentRecord + reason: str + scheduling_obligation: ControlSchedulingObligation | None = None + + +@dataclass(frozen=True, slots=True) +class ExecutionControlHandledRecord: + """Candidate record fully handled by queue-local semantics.""" + + record: CandidateIntentRecord + reason: str + + +@dataclass(frozen=True, slots=True) +class ExecutionControlApplyResult: + """Result of mutable Execution Control apply over one plan state.""" + + queued_effective_records: tuple[CandidateIntentRecord, ...] = () + dispatchable_records: tuple[ExecutionControlDispatchableRecord, ...] = () + execution_handled_records: tuple[ExecutionControlHandledRecord, ...] = () + blocked_records: tuple[ExecutionControlBlockedRecord, ...] = () + control_scheduling_obligation: ControlSchedulingObligation | None = None + execution_control_decision: ExecutionControlDecision = field( + default_factory=ExecutionControlDecision + ) + + def __post_init__(self) -> None: + if not isinstance(self.queued_effective_records, tuple): + object.__setattr__( + self, + "queued_effective_records", + tuple(self.queued_effective_records), + ) + if not isinstance(self.dispatchable_records, tuple): + object.__setattr__( + self, + "dispatchable_records", + tuple(self.dispatchable_records), + ) + if not isinstance(self.execution_handled_records, tuple): + object.__setattr__( + self, + "execution_handled_records", + tuple(self.execution_handled_records), + ) + if not isinstance(self.blocked_records, tuple): + object.__setattr__( + self, + "blocked_records", + tuple(self.blocked_records), + ) + + +def _float_equal(a: float, b: float) -> bool: + return abs(a - b) <= 1e-12 + + +def _select_effective_control_scheduling_obligation( + obligations: list[ControlSchedulingObligation], +) -> ControlSchedulingObligation | None: + if not obligations: + return None + return min( + obligations, + key=lambda obligation: ( + obligation.due_ts_ns_local, + obligation.obligation_key, + ), + ) + + +def _record_is_currently_queued( + state: StrategyState, + record: CandidateIntentRecord, +) -> bool: + queue = state.queued_intents.get(record.intent.instrument) + if queue is None: + return False + return any( + queued.intent == record.intent and queued.logical_key == record.logical_key + for queued in queue + ) + + +def apply_execution_control_plan( + plan: ExecutionControlPlan, + context: ExecutionControlApplyContext, +) -> ExecutionControlApplyResult: + """Apply mutable Execution Control semantics over planned active records. + + This function mutates only StrategyState Queue data and ExecutionControl + rate state. It does not perform venue dispatch and does not emit canonical + events. + + ``control_scheduling_obligation`` is selected only from **rate-limit** + deferrals (time-dependent). **Inflight** gating queues or blocks work without + adding a scheduling obligation; that case is resolved when later canonical + events update sendability (not via a Core-derived wake time in this slice). + """ + + state = context.state + execution_control = context.execution_control + + dispatchable_records: list[ExecutionControlDispatchableRecord] = [] + execution_handled_records: list[ExecutionControlHandledRecord] = [] + blocked_records: list[ExecutionControlBlockedRecord] = [] + obligations: list[ControlSchedulingObligation] = [] + + processed_keys: set[str] = set() + + to_queue_by_instr: defaultdict[str, list] = defaultdict(list) + replaced_in_queue: list[tuple] = [] + dropped_in_queue: list = [] + queued: list = [] + handled_in_queue: list = [] + + for record in plan.active_records: + if record.logical_key in processed_keys: + execution_handled_records.append( + ExecutionControlHandledRecord( + record=record, + reason="duplicate_candidate_record", + ) + ) + continue + processed_keys.add(record.logical_key) + + intent = record.intent + instrument = intent.instrument + + if record.origin == CandidateIntentOrigin.GENERATED: + to_queue_before = len(to_queue_by_instr[instrument]) + handled_before = len(handled_in_queue) + continue_to_sendability, reject_reason = ( + execution_control.route_pre_submission_lifecycle_and_inflight( + intent, + state=state, + to_queue_by_instr=to_queue_by_instr, + replaced_in_queue=replaced_in_queue, + dropped_in_queue=dropped_in_queue, + queued=queued, + handled_in_queue=handled_in_queue, + float_equal=_float_equal, + ) + ) + if not continue_to_sendability: + to_queue_after = len(to_queue_by_instr[instrument]) + handled_after = len(handled_in_queue) + if reject_reason is not None: + blocked_records.append( + ExecutionControlBlockedRecord( + record=record, + reason=reject_reason, + ) + ) + continue + if to_queue_after > to_queue_before: + blocked_records.append( + ExecutionControlBlockedRecord( + record=record, + reason="inflight", + ) + ) + continue + if handled_after > handled_before: + execution_handled_records.append( + ExecutionControlHandledRecord( + record=record, + reason="queue_local_handled", + ) + ) + continue + execution_handled_records.append( + ExecutionControlHandledRecord( + record=record, + reason="handled", + ) + ) + continue + + rate_result = execution_control.route_after_policy_rate_limit( + intent, + now_ts_ns_local=context.now_ts_ns_local, + max_orders_per_sec=context.max_orders_per_sec, + max_cancels_per_sec=context.max_cancels_per_sec, + ) + if rate_result.stage_to_queue: + to_queue_by_instr[instrument].append(intent) + blocked_records.append( + ExecutionControlBlockedRecord( + record=record, + reason="rate_limit", + scheduling_obligation=rate_result.scheduling_obligation, + ) + ) + if rate_result.scheduling_obligation is not None: + obligations.append(rate_result.scheduling_obligation) + continue + + dispatchable_records.append(ExecutionControlDispatchableRecord(record=record)) + continue + + detached = state.pop_queued_intents_for_order( + intent.instrument, + intent.client_order_id, + ) + detached_intents = [queued_item.intent for queued_item in detached] + if not detached_intents: + execution_handled_records.append( + ExecutionControlHandledRecord( + record=record, + reason="queued_record_missing", + ) + ) + continue + + if intent.intent_type in ("new", "replace") and state.has_inflight( + intent.instrument, intent.client_order_id + ): + state.merge_intents_into_queue( + instrument=intent.instrument, + intents=detached_intents, + ) + blocked_records.append( + ExecutionControlBlockedRecord( + record=record, + reason="inflight", + ) + ) + continue + + rate_result = execution_control.route_after_policy_rate_limit( + intent, + now_ts_ns_local=context.now_ts_ns_local, + max_orders_per_sec=context.max_orders_per_sec, + max_cancels_per_sec=context.max_cancels_per_sec, + ) + if rate_result.stage_to_queue: + state.merge_intents_into_queue( + instrument=intent.instrument, + intents=detached_intents, + ) + blocked_records.append( + ExecutionControlBlockedRecord( + record=record, + reason="rate_limit", + scheduling_obligation=rate_result.scheduling_obligation, + ) + ) + if rate_result.scheduling_obligation is not None: + obligations.append(rate_result.scheduling_obligation) + continue + + dispatchable_records.append(ExecutionControlDispatchableRecord(record=record)) + + execution_control.merge_to_queue_per_instrument( + state=state, + to_queue_by_instr=to_queue_by_instr, + queued=queued, + replaced_in_queue=replaced_in_queue, + dropped_in_queue=dropped_in_queue, + ) + + queued_effective_records = tuple( + record + for record in plan.active_records + if _record_is_currently_queued(state, record) + ) + control_scheduling_obligation = _select_effective_control_scheduling_obligation( + obligations + ) + decision = ExecutionControlDecision( + queued_effective_intents=tuple( + record.intent for record in queued_effective_records + ), + dispatchable_intents=tuple( + item.record.intent for item in dispatchable_records + ), + execution_handled_intents=tuple( + item.record.intent for item in execution_handled_records + ), + control_scheduling_obligation=control_scheduling_obligation, + ) + + return ExecutionControlApplyResult( + queued_effective_records=queued_effective_records, + dispatchable_records=tuple(dispatchable_records), + execution_handled_records=tuple(execution_handled_records), + blocked_records=tuple(blocked_records), + control_scheduling_obligation=control_scheduling_obligation, + execution_control_decision=decision, + ) diff --git a/tradingchassis_core/core/domain/execution_control_decision.py b/tradingchassis_core/core/domain/execution_control_decision.py new file mode 100644 index 0000000..e40ff8e --- /dev/null +++ b/tradingchassis_core/core/domain/execution_control_decision.py @@ -0,0 +1,38 @@ +"""Core-owned Execution Control decision model.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from tradingchassis_core.core.domain.types import OrderIntent +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation + + +@dataclass(frozen=True, slots=True) +class ExecutionControlDecision: + """Immutable non-canonical Execution Control outcome.""" + + queued_effective_intents: tuple[OrderIntent, ...] = () + dispatchable_intents: tuple[OrderIntent, ...] = () + execution_handled_intents: tuple[OrderIntent, ...] = () + control_scheduling_obligation: ControlSchedulingObligation | None = None + + def __post_init__(self) -> None: + if not isinstance(self.queued_effective_intents, tuple): + object.__setattr__( + self, + "queued_effective_intents", + tuple(self.queued_effective_intents), + ) + if not isinstance(self.dispatchable_intents, tuple): + object.__setattr__( + self, + "dispatchable_intents", + tuple(self.dispatchable_intents), + ) + if not isinstance(self.execution_handled_intents, tuple): + object.__setattr__( + self, + "execution_handled_intents", + tuple(self.execution_handled_intents), + ) diff --git a/tradingchassis_core/core/domain/execution_control_plan.py b/tradingchassis_core/core/domain/execution_control_plan.py new file mode 100644 index 0000000..4b000d4 --- /dev/null +++ b/tradingchassis_core/core/domain/execution_control_plan.py @@ -0,0 +1,96 @@ +"""Pure, non-canonical Execution Control candidate planning scaffolds.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from tradingchassis_core.core.domain.candidate_intent import CandidateIntentRecord +from tradingchassis_core.core.domain.execution_control_decision import ( + ExecutionControlDecision, +) + + +@dataclass(frozen=True, slots=True) +class ExecutionControlCandidateInput: + """Policy-admitted candidate records for capture-only Execution Control planning.""" + + accepted_generated: tuple[CandidateIntentRecord, ...] = () + passthrough_queued: tuple[CandidateIntentRecord, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.accepted_generated, tuple): + object.__setattr__( + self, + "accepted_generated", + tuple(self.accepted_generated), + ) + if not isinstance(self.passthrough_queued, tuple): + object.__setattr__( + self, + "passthrough_queued", + tuple(self.passthrough_queued), + ) + + +@dataclass(frozen=True, slots=True) +class ExecutionControlPlan: + """Capture-only Execution Control candidate planning result.""" + + active_records: tuple[CandidateIntentRecord, ...] = () + queued_effective_records: tuple[CandidateIntentRecord, ...] = () + dispatchable_records: tuple[CandidateIntentRecord, ...] = () + execution_handled_records: tuple[CandidateIntentRecord, ...] = () + execution_control_decision: ExecutionControlDecision = field( + default_factory=ExecutionControlDecision + ) + + def __post_init__(self) -> None: + if not isinstance(self.active_records, tuple): + object.__setattr__(self, "active_records", tuple(self.active_records)) + if not isinstance(self.queued_effective_records, tuple): + object.__setattr__( + self, + "queued_effective_records", + tuple(self.queued_effective_records), + ) + if not isinstance(self.dispatchable_records, tuple): + object.__setattr__( + self, + "dispatchable_records", + tuple(self.dispatchable_records), + ) + if not isinstance(self.execution_handled_records, tuple): + object.__setattr__( + self, + "execution_handled_records", + tuple(self.execution_handled_records), + ) + + +def plan_execution_control_candidates( + planning_input: ExecutionControlCandidateInput, +) -> ExecutionControlPlan: + """Build a deterministic, side-effect-free Execution Control plan projection.""" + + active_records = ( + tuple(planning_input.accepted_generated) + + tuple(planning_input.passthrough_queued) + ) + queued_effective_records = active_records + dispatchable_records: tuple[CandidateIntentRecord, ...] = () + execution_handled_records: tuple[CandidateIntentRecord, ...] = () + + return ExecutionControlPlan( + active_records=active_records, + queued_effective_records=queued_effective_records, + dispatchable_records=dispatchable_records, + execution_handled_records=execution_handled_records, + execution_control_decision=ExecutionControlDecision( + queued_effective_intents=tuple( + record.intent for record in queued_effective_records + ), + dispatchable_intents=(), + execution_handled_intents=(), + control_scheduling_obligation=None, + ), + ) diff --git a/tradingchassis_core/core/domain/intent_combination.py b/tradingchassis_core/core/domain/intent_combination.py new file mode 100644 index 0000000..5f575f7 --- /dev/null +++ b/tradingchassis_core/core/domain/intent_combination.py @@ -0,0 +1,100 @@ +"""Pure helper for Core-step candidate intent combination.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from tradingchassis_core.core.domain.candidate_intent import ( + CandidateIntentOrigin, + CandidateIntentRecord, +) +from tradingchassis_core.core.domain.types import OrderIntent + + +def _logical_key(intent: OrderIntent) -> str: + return f"order:{intent.client_order_id}" + + +def _intent_priority(intent: OrderIntent) -> int: + if intent.intent_type == "cancel": + return 0 + if intent.intent_type == "replace": + return 1 + if intent.intent_type == "new": + return 2 + return 9 + + +def _dominance_rank(intent: OrderIntent) -> int: + if intent.intent_type == "cancel": + return 3 + if intent.intent_type == "replace": + return 2 + if intent.intent_type == "new": + return 1 + return 0 + + +def combine_candidate_intents( + *, + generated_intents: Sequence[OrderIntent], + queued_intents: Sequence[OrderIntent], +) -> tuple[OrderIntent, ...]: + """Compatibility helper returning only effective intent values. + + Prefer ``combine_candidate_intent_records`` when origin/provenance is needed. + """ + records = combine_candidate_intent_records( + generated_intents=generated_intents, + queued_intents=queued_intents, + ) + return tuple(record.intent for record in records) + + +def combine_candidate_intent_records( + *, + generated_intents: Sequence[OrderIntent], + queued_intents: Sequence[OrderIntent], +) -> tuple[CandidateIntentRecord, ...]: + """Combine queued + generated intents into a deterministic effective set. + + This helper is pure and does not mutate StrategyState. + Merge order is deterministic: queued first, then generated. + """ + merged: list[tuple[OrderIntent, CandidateIntentOrigin]] = [ + *((intent, CandidateIntentOrigin.QUEUED) for intent in queued_intents), + *((intent, CandidateIntentOrigin.GENERATED) for intent in generated_intents), + ] + # key -> winning record + effective_by_key: dict[str, CandidateIntentRecord] = {} + + for merge_index, (intent, origin) in enumerate(merged): + key = _logical_key(intent) + incoming = CandidateIntentRecord( + intent=intent, + origin=origin, + logical_key=key, + merge_index=merge_index, + priority=_intent_priority(intent), + ) + existing = effective_by_key.get(key) + if existing is None: + effective_by_key[key] = incoming + continue + + incoming_rank = _dominance_rank(incoming.intent) + existing_rank = _dominance_rank(existing.intent) + if incoming_rank > existing_rank: + effective_by_key[key] = incoming + continue + if incoming_rank < existing_rank: + continue + + # Same-type conflict: latest in deterministic merge order wins. + effective_by_key[key] = incoming + + ordered = sorted( + effective_by_key.values(), + key=lambda item: (item.priority, item.merge_index, item.logical_key), + ) + return tuple(ordered) diff --git a/tradingchassis_core/core/domain/order_lifecycle.py b/tradingchassis_core/core/domain/order_lifecycle.py deleted file mode 100644 index 9a2eb1b..0000000 --- a/tradingchassis_core/core/domain/order_lifecycle.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -Canonical internal order lifecycle policy. - -This module defines a lightweight, internal-only lifecycle policy used by the -canonical order projection. Compatibility order states are normalized into -canonical lifecycle candidates before transition validation. -""" - -from __future__ import annotations - -CANONICAL_ORDER_STATES: frozenset[str] = frozenset( - { - "submitted", - "accepted", - "partially_filled", - "filled", - "canceled", - "rejected", - } -) - -CANONICAL_TERMINAL_ORDER_STATES: frozenset[str] = frozenset( - { - "filled", - "canceled", - "rejected", - } -) - -CANONICAL_ALLOWED_TRANSITIONS: dict[str, frozenset[str]] = { - "submitted": frozenset({"accepted", "rejected"}), - "accepted": frozenset({"partially_filled", "filled", "canceled"}), - "partially_filled": frozenset({"partially_filled", "filled", "canceled"}), - "filled": frozenset(), - "canceled": frozenset(), - "rejected": frozenset(), -} - -_COMPAT_TO_CANONICAL: dict[str, str | None] = { - "pending_new": None, - "accepted": "accepted", - "working": "accepted", - "partially_filled": "partially_filled", - "filled": "filled", - "canceled": "canceled", - "rejected": "rejected", - "replaced": None, - # Keep "expired" as compatibility/deferred for this slice. - "expired": None, -} - - -def normalize_compatibility_state_to_canonical(state_type: str) -> str | None: - """Map compatibility state values to canonical lifecycle candidates.""" - return _COMPAT_TO_CANONICAL.get(state_type) - - -def is_valid_canonical_order_transition(prev_state: str, next_state: str) -> bool: - """Return True when prev_state -> next_state is allowed canonically.""" - allowed = CANONICAL_ALLOWED_TRANSITIONS.get(prev_state) - if allowed is None: - return False - return next_state in allowed diff --git a/tradingchassis_core/core/domain/order_state_machine.py b/tradingchassis_core/core/domain/order_state_machine.py deleted file mode 100644 index e7463c2..0000000 --- a/tradingchassis_core/core/domain/order_state_machine.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -Order lifecycle state machine definitions. - -This module defines the canonical order states and the allowed transitions -between them. It is intentionally passive and validation-only. - -The state machine is snapshot-driven and designed for observability, -debugging, and research instrumentation. It must NOT enforce behavior -or raise exceptions in production paths. -""" - -from __future__ import annotations - -# Terminal order states: once reached, the order is considered complete. -ORDER_TERMINAL_STATES: frozenset[str] = frozenset( - { - "filled", - "canceled", - "expired", - "rejected", - } -) - - -# Allowed order state transitions. -# -# Key : previous state (or None if order was not previously observed) -# Value : set of allowed next states -# -# Notes: -# - The state machine is best-effort and snapshot-driven. -# - Repeated states (e.g. partially_filled -> partially_filled) are allowed. -# - This definition is intentionally conservative and venue-agnostic. -ORDER_ALLOWED_TRANSITIONS: dict[str | None, frozenset[str]] = { - None: frozenset({"pending_new"}), - - "pending_new": frozenset( - { - "accepted", - "rejected", - } - ), - - "accepted": frozenset( - { - "working", - "canceled", - "rejected", - } - ), - - "working": frozenset( - { - "working", - "partially_filled", - "filled", - "canceled", - "replaced", - } - ), - - "partially_filled": frozenset( - { - "partially_filled", - "filled", - "canceled", - "replaced", - } - ), -} - - -def is_terminal_state(state: str) -> bool: - """Return True if the given state is terminal.""" - return state in ORDER_TERMINAL_STATES - - -def is_valid_transition(prev_state: str | None, next_state: str) -> bool: - """Return True if the transition prev_state -> next_state is allowed.""" - allowed = ORDER_ALLOWED_TRANSITIONS.get(prev_state) - if allowed is None: - return False - return next_state in allowed diff --git a/tradingchassis_core/core/domain/policy_risk_decision.py b/tradingchassis_core/core/domain/policy_risk_decision.py new file mode 100644 index 0000000..19e364e --- /dev/null +++ b/tradingchassis_core/core/domain/policy_risk_decision.py @@ -0,0 +1,120 @@ +"""Core-owned policy-risk decision model and policy admission helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Protocol, Sequence + +from tradingchassis_core.core.domain.candidate_intent import ( + CandidateIntentOrigin, + CandidateIntentRecord, +) +from tradingchassis_core.core.domain.types import OrderIntent + +if TYPE_CHECKING: + from tradingchassis_core.core.domain.state import StrategyState + + +class PolicyIntentEvaluator(Protocol): + """Side-effect-safe policy evaluator contract for one candidate intent.""" + + def evaluate_policy_intent( + self, + *, + intent: OrderIntent, + state: StrategyState, + now_ts_ns_local: int, + ) -> tuple[bool, str | None]: + """Return (accepted, reason_if_rejected).""" + + +@dataclass(frozen=True, slots=True) +class PolicyRiskDecision: + """Immutable non-canonical policy admissibility projection.""" + + accepted_intents: tuple[OrderIntent, ...] = () + rejected_intents: tuple[OrderIntent, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.accepted_intents, tuple): + object.__setattr__(self, "accepted_intents", tuple(self.accepted_intents)) + if not isinstance(self.rejected_intents, tuple): + object.__setattr__(self, "rejected_intents", tuple(self.rejected_intents)) + + +@dataclass(frozen=True, slots=True) +class PolicyRejectedCandidate: + """Generated-origin candidate denied by policy with preserved reason.""" + + record: CandidateIntentRecord + reason: str + + +@dataclass(frozen=True, slots=True) +class PolicyAdmissionResult: + """Result of side-effect-safe policy admission over candidate records.""" + + accepted_generated: tuple[CandidateIntentRecord, ...] = () + rejected_generated: tuple[PolicyRejectedCandidate, ...] = () + passthrough_queued: tuple[CandidateIntentRecord, ...] = () + policy_risk_decision: PolicyRiskDecision = field(default_factory=PolicyRiskDecision) + + def __post_init__(self) -> None: + if not isinstance(self.accepted_generated, tuple): + object.__setattr__(self, "accepted_generated", tuple(self.accepted_generated)) + if not isinstance(self.rejected_generated, tuple): + object.__setattr__(self, "rejected_generated", tuple(self.rejected_generated)) + if not isinstance(self.passthrough_queued, tuple): + object.__setattr__(self, "passthrough_queued", tuple(self.passthrough_queued)) + + +def apply_policy_to_candidate_records( + candidate_records: Sequence[CandidateIntentRecord], + *, + state: StrategyState, + now_ts_ns_local: int, + policy_evaluator: PolicyIntentEvaluator, +) -> PolicyAdmissionResult: + """Apply policy admission to generated-origin candidates only.""" + + accepted_generated: list[CandidateIntentRecord] = [] + rejected_generated: list[PolicyRejectedCandidate] = [] + passthrough_queued: list[CandidateIntentRecord] = [] + + accepted_intents: list[OrderIntent] = [] + rejected_intents: list[OrderIntent] = [] + + for record in candidate_records: + if record.origin == CandidateIntentOrigin.QUEUED: + passthrough_queued.append(record) + continue + if record.origin != CandidateIntentOrigin.GENERATED: + raise ValueError(f"Unsupported CandidateIntentOrigin: {record.origin!r}") + + accepted, reason = policy_evaluator.evaluate_policy_intent( + intent=record.intent, + state=state, + now_ts_ns_local=now_ts_ns_local, + ) + if accepted: + accepted_generated.append(record) + accepted_intents.append(record.intent) + continue + + rejected_generated.append( + PolicyRejectedCandidate( + record=record, + reason=reason or "policy_rejected", + ) + ) + rejected_intents.append(record.intent) + + return PolicyAdmissionResult( + accepted_generated=tuple(accepted_generated), + rejected_generated=tuple(rejected_generated), + passthrough_queued=tuple(passthrough_queued), + policy_risk_decision=PolicyRiskDecision( + accepted_intents=tuple(accepted_intents), + rejected_intents=tuple(rejected_intents), + ), + ) diff --git a/tradingchassis_core/core/domain/processing.py b/tradingchassis_core/core/domain/processing.py index 85ac603..d15c8a0 100644 --- a/tradingchassis_core/core/domain/processing.py +++ b/tradingchassis_core/core/domain/processing.py @@ -1,7 +1,7 @@ -"""Minimal canonical event processing boundary for core. +"""Minimal canonical Event processing boundary for core. This module introduces a narrow, docs-aligned processing boundary for current -canonical event candidates. For these candidates, ``process_canonical_event`` +canonical Event candidates. For these candidates, ``process_canonical_event`` is the preferred top-level canonical state-advance entrypoint in core. This module is intentionally small: @@ -29,6 +29,7 @@ ControlTimeEvent, FillEvent, MarketEvent, + OrderExecutionFeedbackEvent, OrderSubmittedEvent, ) @@ -99,7 +100,7 @@ def process_canonical_event( position: ProcessingPosition | None = None, configuration: CoreConfiguration | None = None, ) -> None: - """Process a canonical event candidate via existing state reducers. + """Process a canonical Event candidate via existing state reducers. Preferred usage for the current slice: - use this function as the top-level canonical ingestion boundary for @@ -110,6 +111,7 @@ def process_canonical_event( - ``MarketEvent`` (category: ``market``) - ``OrderSubmittedEvent`` (category: ``intent_related``) - ``FillEvent`` (category: ``execution``) + - ``OrderExecutionFeedbackEvent`` (category: ``execution``) - ``ControlTimeEvent`` (category: ``control``) ``ProcessingPosition`` is accepted as Processing Order metadata at this @@ -121,7 +123,7 @@ def process_canonical_event( """ record_type = type(event) if not is_canonical_stream_candidate_type(record_type): - raise TypeError(f"Unsupported non-canonical event type: {record_type.__name__}") + raise TypeError(f"Unsupported non-canonical Event type: {record_type.__name__}") category = canonical_category_for_type(record_type) @@ -179,6 +181,15 @@ def process_canonical_event( state.apply_fill_event(event) return + if ( + category == CanonicalEventCategory.EXECUTION + and isinstance(event, OrderExecutionFeedbackEvent) + ): + if position is not None: + state._advance_processing_position(position) + state.apply_order_execution_feedback_event(event) + return + if ( category == CanonicalEventCategory.INTENT_RELATED and isinstance(event, OrderSubmittedEvent) @@ -195,7 +206,7 @@ def process_canonical_event( return raise TypeError( - "Unsupported canonical event candidate for this processing boundary: " + "Unsupported canonical Event candidate for this processing boundary: " f"{record_type.__name__}" ) diff --git a/tradingchassis_core/core/domain/processing_order.py b/tradingchassis_core/core/domain/processing_order.py index dad7cc1..8872b8b 100644 --- a/tradingchassis_core/core/domain/processing_order.py +++ b/tradingchassis_core/core/domain/processing_order.py @@ -22,7 +22,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) class EventStreamEntry: - """Minimal envelope for canonical event processing-order input. + """Minimal envelope for canonical Event processing-order input. This value object intentionally carries only: - the causal processing-order position; and diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py new file mode 100644 index 0000000..711c957 --- /dev/null +++ b/tradingchassis_core/core/domain/processing_step.py @@ -0,0 +1,367 @@ +"""Deterministic Core step orchestration over canonical reducer inputs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol, Sequence + +from tradingchassis_core.core.domain.configuration import CoreConfiguration +from tradingchassis_core.core.domain.execution_control_apply import ( + ExecutionControlApplyContext, + apply_execution_control_plan, +) +from tradingchassis_core.core.domain.execution_control_decision import ( + ExecutionControlDecision, +) +from tradingchassis_core.core.domain.execution_control_plan import ( + ExecutionControlCandidateInput, + plan_execution_control_candidates, +) +from tradingchassis_core.core.domain.intent_combination import ( + combine_candidate_intent_records, +) +from tradingchassis_core.core.domain.policy_risk_decision import ( + PolicyAdmissionResult, + PolicyIntentEvaluator, + apply_policy_to_candidate_records, +) +from tradingchassis_core.core.domain.processing import process_event_entry +from tradingchassis_core.core.domain.processing_order import EventStreamEntry, ProcessingPosition +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.step_decision import CoreStepDecision +from tradingchassis_core.core.domain.step_result import CoreStepResult +from tradingchassis_core.core.domain.types import OrderIntent + +if TYPE_CHECKING: + from tradingchassis_core.core.execution_control.execution_control import ExecutionControl + + +@dataclass(frozen=True, slots=True) +class CoreStepStrategyContext: + """Deterministic Strategy-evaluation context for one Core step.""" + + state: StrategyState + event: object + position: ProcessingPosition + configuration: CoreConfiguration | None = None + + +class CoreStepStrategyEvaluator(Protocol): + """Core-owned Strategy evaluation protocol for unified step semantics.""" + + def evaluate(self, context: CoreStepStrategyContext) -> Sequence[OrderIntent]: + """Evaluate Strategy once for the provided step context.""" + + +@dataclass(frozen=True, slots=True) +class CoreWakeupStrategyContext: + """Deterministic Strategy-evaluation context for one Core wakeup batch.""" + + state: StrategyState + entries: tuple[EventStreamEntry, ...] + configuration: CoreConfiguration | None = None + last_position: ProcessingPosition | None = None + + +class CoreWakeupStrategyEvaluator(Protocol): + """Core-owned Strategy evaluation protocol for one wakeup batch.""" + + def evaluate(self, context: CoreWakeupStrategyContext) -> Sequence[OrderIntent]: + """Evaluate Strategy once after all wakeup entries are reduced.""" + + +@dataclass(frozen=True, slots=True) +class CorePolicyAdmissionContext: + """Optional side-effect-safe policy admission capture context.""" + + policy_evaluator: PolicyIntentEvaluator + now_ts_ns_local: int + + +@dataclass(frozen=True, slots=True) +class CoreExecutionControlApplyContext: + """Optional mutable Execution Control apply context for one Core step.""" + + execution_control: ExecutionControl + now_ts_ns_local: int + max_orders_per_sec: float | None = None + max_cancels_per_sec: float | None = None + activate_dispatchable_outputs: bool = False + + +@dataclass(frozen=True, slots=True) +class CoreWakeupReductionResult: + """Non-canonical reduction-phase output for one runtime wakeup.""" + + entries: tuple[EventStreamEntry, ...] = () + generated_intents: tuple[OrderIntent, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.entries, tuple): + object.__setattr__(self, "entries", tuple(self.entries)) + if not isinstance(self.generated_intents, tuple): + object.__setattr__(self, "generated_intents", tuple(self.generated_intents)) + + +def _resolve_candidate_instrument(*, entry: EventStreamEntry) -> str | None: + event_instrument = getattr(entry.event, "instrument", None) + if isinstance(event_instrument, str): + return event_instrument + return None + + +def _to_core_step_decision( + *, + policy_result: PolicyAdmissionResult, + execution_control_decision: ExecutionControlDecision, +) -> CoreStepDecision: + return CoreStepDecision( + policy_rejected_intents=tuple( + rejected.record.intent for rejected in policy_result.rejected_generated + ), + policy_risk_decision=policy_result.policy_risk_decision, + execution_control_decision=execution_control_decision, + queued_effective_intents=execution_control_decision.queued_effective_intents, + dispatchable_intents=execution_control_decision.dispatchable_intents, + execution_handled_intents=execution_control_decision.execution_handled_intents, + control_scheduling_obligation=execution_control_decision.control_scheduling_obligation, + ) + + +def run_core_step( + state: StrategyState, + entry: EventStreamEntry, + *, + configuration: CoreConfiguration | None = None, + policy_admission_context: CorePolicyAdmissionContext | None = None, + execution_control_apply_context: CoreExecutionControlApplyContext | None = None, + strategy_evaluator: CoreStepStrategyEvaluator | None = None, +) -> CoreStepResult: + """Run one deterministic Core step.""" + if execution_control_apply_context is not None and policy_admission_context is None: + raise ValueError( + "execution_control_apply_context requires policy_admission_context" + ) + + process_event_entry(state, entry, configuration=configuration) + + generated_intents: tuple[OrderIntent, ...] = () + if strategy_evaluator is not None: + strategy_context = CoreStepStrategyContext( + state=state, + event=entry.event, + position=entry.position, + configuration=configuration, + ) + generated_intents = tuple(strategy_evaluator.evaluate(strategy_context)) + + queued_instrument = _resolve_candidate_instrument(entry=entry) + queued_snapshot = state.queued_intents_snapshot(queued_instrument) + candidate_intent_records = combine_candidate_intent_records( + generated_intents=generated_intents, + queued_intents=queued_snapshot, + ) + candidate_intents = tuple(record.intent for record in candidate_intent_records) + + if policy_admission_context is None: + return CoreStepResult( + generated_intents=generated_intents, + candidate_intent_records=candidate_intent_records, + candidate_intents=candidate_intents, + ) + + policy_result = apply_policy_to_candidate_records( + candidate_intent_records, + state=state, + now_ts_ns_local=policy_admission_context.now_ts_ns_local, + policy_evaluator=policy_admission_context.policy_evaluator, + ) + execution_control_plan = plan_execution_control_candidates( + ExecutionControlCandidateInput( + accepted_generated=policy_result.accepted_generated, + passthrough_queued=policy_result.passthrough_queued, + ) + ) + + apply_result = None + if execution_control_apply_context is not None: + apply_result = apply_execution_control_plan( + execution_control_plan, + ExecutionControlApplyContext( + state=state, + execution_control=execution_control_apply_context.execution_control, + now_ts_ns_local=execution_control_apply_context.now_ts_ns_local, + max_orders_per_sec=execution_control_apply_context.max_orders_per_sec, + max_cancels_per_sec=execution_control_apply_context.max_cancels_per_sec, + ), + ) + + effective_execution_control_decision = ( + execution_control_plan.execution_control_decision + if apply_result is None + else apply_result.execution_control_decision + ) + core_step_decision = _to_core_step_decision( + policy_result=policy_result, + execution_control_decision=effective_execution_control_decision, + ) + + dispatchable_intents: tuple[OrderIntent, ...] = () + control_scheduling_obligation = None + if apply_result is not None: + control_scheduling_obligation = apply_result.control_scheduling_obligation + if ( + execution_control_apply_context is not None + and execution_control_apply_context.activate_dispatchable_outputs + ): + dispatchable_intents = tuple( + record.record.intent for record in apply_result.dispatchable_records + ) + + return CoreStepResult( + generated_intents=generated_intents, + candidate_intent_records=candidate_intent_records, + candidate_intents=candidate_intents, + dispatchable_intents=dispatchable_intents, + control_scheduling_obligation=control_scheduling_obligation, + core_step_decision=core_step_decision, + ) + + +def run_core_wakeup_reduction( + state: StrategyState, + entries: Sequence[EventStreamEntry], + *, + configuration: CoreConfiguration | None = None, + wakeup_strategy_evaluator: CoreWakeupStrategyEvaluator | None = None, +) -> CoreWakeupReductionResult: + """Reduce multiple canonical entries in order for one runtime wakeup.""" + entries_tuple = tuple(entries) + for entry in entries_tuple: + process_event_entry(state, entry, configuration=configuration) + + generated_intents: tuple[OrderIntent, ...] = () + if wakeup_strategy_evaluator is not None: + last_position = entries_tuple[-1].position if entries_tuple else None + wakeup_context = CoreWakeupStrategyContext( + state=state, + entries=entries_tuple, + configuration=configuration, + last_position=last_position, + ) + generated_intents = tuple(wakeup_strategy_evaluator.evaluate(wakeup_context)) + + return CoreWakeupReductionResult( + entries=entries_tuple, + generated_intents=generated_intents, + ) + + +def run_core_wakeup_decision( + state: StrategyState, + reduction: CoreWakeupReductionResult, + *, + queued_instrument: str | None = None, + policy_admission_context: CorePolicyAdmissionContext | None = None, + execution_control_apply_context: CoreExecutionControlApplyContext | None = None, +) -> CoreStepResult: + """Run one wakeup-level candidate/policy/Execution Control decision phase.""" + + if execution_control_apply_context is not None and policy_admission_context is None: + raise ValueError( + "execution_control_apply_context requires policy_admission_context" + ) + + queued_snapshot = state.queued_intents_snapshot(queued_instrument) + candidate_intent_records = combine_candidate_intent_records( + generated_intents=reduction.generated_intents, + queued_intents=queued_snapshot, + ) + candidate_intents = tuple(record.intent for record in candidate_intent_records) + + if policy_admission_context is None: + return CoreStepResult( + generated_intents=reduction.generated_intents, + candidate_intent_records=candidate_intent_records, + candidate_intents=candidate_intents, + ) + + policy_result = apply_policy_to_candidate_records( + candidate_intent_records, + state=state, + now_ts_ns_local=policy_admission_context.now_ts_ns_local, + policy_evaluator=policy_admission_context.policy_evaluator, + ) + execution_control_plan = plan_execution_control_candidates( + ExecutionControlCandidateInput( + accepted_generated=policy_result.accepted_generated, + passthrough_queued=policy_result.passthrough_queued, + ) + ) + apply_result = None + if execution_control_apply_context is not None: + apply_result = apply_execution_control_plan( + execution_control_plan, + ExecutionControlApplyContext( + state=state, + execution_control=execution_control_apply_context.execution_control, + now_ts_ns_local=execution_control_apply_context.now_ts_ns_local, + max_orders_per_sec=execution_control_apply_context.max_orders_per_sec, + max_cancels_per_sec=execution_control_apply_context.max_cancels_per_sec, + ), + ) + effective_execution_control_decision = ( + execution_control_plan.execution_control_decision + if apply_result is None + else apply_result.execution_control_decision + ) + core_step_decision = _to_core_step_decision( + policy_result=policy_result, + execution_control_decision=effective_execution_control_decision, + ) + dispatchable_intents: tuple[OrderIntent, ...] = () + control_scheduling_obligation = None + if apply_result is not None: + control_scheduling_obligation = apply_result.control_scheduling_obligation + if ( + execution_control_apply_context is not None + and execution_control_apply_context.activate_dispatchable_outputs + ): + dispatchable_intents = tuple( + record.record.intent for record in apply_result.dispatchable_records + ) + return CoreStepResult( + generated_intents=reduction.generated_intents, + candidate_intent_records=candidate_intent_records, + candidate_intents=candidate_intents, + dispatchable_intents=dispatchable_intents, + control_scheduling_obligation=control_scheduling_obligation, + core_step_decision=core_step_decision, + ) + + +def run_core_wakeup_step( + state: StrategyState, + entries: Sequence[EventStreamEntry], + *, + configuration: CoreConfiguration | None = None, + wakeup_strategy_evaluator: CoreWakeupStrategyEvaluator | None = None, + queued_instrument: str | None = None, + policy_admission_context: CorePolicyAdmissionContext | None = None, + execution_control_apply_context: CoreExecutionControlApplyContext | None = None, +) -> CoreStepResult: + """Convenience wrapper for reduction + wakeup-level decision/apply.""" + + reduction = run_core_wakeup_reduction( + state, + entries, + configuration=configuration, + wakeup_strategy_evaluator=wakeup_strategy_evaluator, + ) + return run_core_wakeup_decision( + state, + reduction, + queued_instrument=queued_instrument, + policy_admission_context=policy_admission_context, + execution_control_apply_context=execution_control_apply_context, + ) diff --git a/tradingchassis_core/core/domain/state.py b/tradingchassis_core/core/domain/state.py index 40de89d..6aab540 100644 --- a/tradingchassis_core/core/domain/state.py +++ b/tradingchassis_core/core/domain/state.py @@ -1,97 +1,33 @@ -"""Runtime strategy state management. +"""Deterministic Core Strategy state. -This module maintains best-effort market, account, order, and queue state -derived from venue snapshots and events. Internal records in this module are -derived-state structures, not canonical Event Stream records. - -It is intentionally stateful and optimized for correctness and determinism -rather than minimal complexity. +This state container keeps canonical reducer-owned data and Execution Control +supporting structures (Queue + inflight tracking). Runtime snapshot parsing and +venue lifecycle adaptation are intentionally out of scope for Core. """ -# pylint: disable=line-too-long,too-many-instance-attributes,too-many-public-methods -# pylint: disable=missing-function-docstring,too-many-locals,too-many-arguments -# pylint: disable=too-many-positional-arguments,too-many-return-statements -# pylint: disable=too-many-boolean-expressions from __future__ import annotations from collections import deque from dataclasses import dataclass -from typing import TYPE_CHECKING, Callable, Iterable +from typing import TYPE_CHECKING, Iterable -from tradingchassis_core.core.domain.order_lifecycle import ( - is_valid_canonical_order_transition, - normalize_compatibility_state_to_canonical, -) -from tradingchassis_core.core.domain.order_state_machine import is_valid_transition from tradingchassis_core.core.domain.processing_order import ProcessingPosition -from tradingchassis_core.core.domain.slots import SlotKey, stable_slot_order_id -from tradingchassis_core.core.domain.types import OrderStateEvent -from tradingchassis_core.core.events.events import ( - DerivedFillEvent, - DerivedPnLEvent, - ExposureDerivedEvent, - OrderStateTransitionEvent, -) if TYPE_CHECKING: from tradingchassis_core.core.domain.types import ( ControlTimeEvent, FillEvent, NewOrderIntent, + OrderExecutionFeedbackEvent, OrderIntent, OrderSubmittedEvent, ) from tradingchassis_core.core.events.event_bus import EventBus -# --------------------------------------------------------------------------- -# Internal state models -# -# These models are intentionally NOT part of the JSON-schema "source of truth". -# They exist to hold runtime state derived from hftbacktest snapshots/events. -# --------------------------------------------------------------------------- - -TERMINAL_ORDER_STATES: set[str] = {"filled", "canceled", "expired", "rejected"} - -_UNKNOWN_CCY: str = "UNKNOWN" -_DEFAULT_QTY_UNIT: str = "contracts" - - -@dataclass(slots=True) -class OrderSnapshot: - """Best-effort compatibility order projection. - - This snapshot-facing structure supports compatibility ingestion/projection - flows and is not canonical lifecycle authority. - """ - - instrument: str - client_order_id: str - - ts_ns_exch: int - ts_ns_local: int - - order_type: str - time_in_force: str - state_type: str - - side: str - - intended_price: float - filled_price: float - - intended_qty: float - cum_filled_qty: float - remaining_qty: float - - # Best-effort request marker from hftbacktest snapshots. - # Convention: 0 indicates no in-flight request. - req: int = 0 - - @dataclass(slots=True) class QueuedIntent: - """An intent stored for later sending (data only, no policy).""" + """An intent stored for later sending (data-only Queue).""" intent: OrderIntent queued_at_ts_ns: int @@ -107,33 +43,17 @@ class InflightInfo: ts_sent_ns_local: int -@dataclass(slots=True) -class CanonicalOrderProjection: - """Internal canonical order lifecycle projection.""" - - instrument: str - client_order_id: str - state: str - submitted_ts_ns_local: int - updated_ts_ns_local: int - - @dataclass(slots=True) class MarketState: """Best-effort market snapshot needed for risk checks.""" - # Receipt (local) time is the strategy time axis. last_ts_ns_local: int = 0 - # Venue time is used as a tie-breaker for replacement-style updates. last_ts_ns_exch: int = 0 - best_bid: float = 0.0 best_ask: float = 0.0 mid: float = 0.0 - best_bid_qty: float = 0.0 best_ask_qty: float = 0.0 - tick_size: float = 0.0 lot_size: float = 0.0 contract_size: float = 1.0 @@ -141,7 +61,7 @@ class MarketState: @dataclass(slots=True) class AccountState: - """Best-effort account values from hftbacktest state_values().""" + """Best-effort account values from canonical execution feedback.""" position: float = 0.0 balance: float = 0.0 @@ -149,58 +69,64 @@ class AccountState: trading_volume: float = 0.0 trading_value: float = 0.0 num_trades: int = 0 - equity: float = 0.0 initial_equity: float = 0.0 realized_pnl: float = 0.0 +@dataclass(slots=True) +class WorkingOrder: + """Canonical in-memory view of an active order.""" + + instrument: str + client_order_id: str + side: str + intended_price: float + intended_qty: float + cum_filled_qty: float + remaining_qty: float + state: str + submitted_ts_ns_local: int + updated_ts_ns_local: int + + +@dataclass(slots=True) +class CanonicalOrderProjection: + """Internal canonical order lifecycle projection.""" + + instrument: str + client_order_id: str + state: str + submitted_ts_ns_local: int + updated_ts_ns_local: int + side: str | None = None + intended_price: float | None = None + intended_qty: float | None = None + + class StrategyState: - """High-level strategy state keyed by instrument.""" + """High-level deterministic Strategy state keyed by instrument.""" def __init__(self, event_bus: EventBus) -> None: self._event_bus = event_bus self.market: dict[str, MarketState] = {} self.account: dict[str, AccountState] = {} - self.orders: dict[str, dict[str, OrderSnapshot]] = {} - # Accumulates OrderStateEvents since the last consumer pop. - # Used to surface edge-events such as "replaced" to strategies. - self.order_events: dict[str, deque[OrderStateEvent]] = {} + self.orders: dict[str, dict[str, WorkingOrder]] = {} self.fills: dict[str, deque[FillEvent]] = {} - # Best-effort idempotence for fill deltas. - # Tracks last observed cumulative filled quantity per (instrument, client_order_id). self.fill_cum_qty: dict[str, dict[str, float]] = {} self.queued_intents: dict[str, deque[QueuedIntent]] = {} - self.inflight: dict[str, dict[str, InflightInfo]] = {} - # Internal canonical lifecycle projection keyed by (instrument, client_order_id). - # This projection is intentionally separate from compatibility snapshots. self.canonical_orders: dict[tuple[str, str], CanonicalOrderProjection] = {} - - # Best-effort tracking of last sent intent per (instrument, client_order_id). - # Mapping: instrument -> client_order_id -> (ts_ns_local, intent_type) self.last_sent_intents: dict[str, dict[str, tuple[int, str]]] = {} - - # Rolling equity series for rolling-loss checks. - # Stores (ts_ns_local, total_equity). self.rolling_equity: deque[tuple[int, float]] = deque() self._last_realized_pnl: dict[str, float] = {} self._last_exposure: dict[str, float] = {} - - # Canonical monotone simulation time (local/receipt axis). - # This is the single time reference used for gating and risk decisions. self.last_ts_ns_local: int = 0 - - # Migration-step Processing Order cursor metadata. - # Private: boundary-owned by process_canonical_event only. self._last_processing_position_index: int | None = None - # ---- Timestamp ---- def update_timestamp(self, ts_ns_local: int) -> None: - # Monotone simulation time: never regress. - # Using max() here makes the policy explicit. self.last_ts_ns_local = max(self.last_ts_ns_local, ts_ns_local) @property @@ -209,32 +135,17 @@ def sim_ts_ns_local(self) -> int: return self.last_ts_ns_local def _advance_processing_position(self, position: ProcessingPosition) -> None: - """Advance private Processing Order cursor for positioned canonical events.""" last = self._last_processing_position_index next_index = position.index - if last is not None and next_index <= last: raise ValueError( "Non-monotonic ProcessingPosition index: " f"received {next_index} after {last}." ) - self._last_processing_position_index = next_index def mark_intent_sent(self, instrument: str, client_order_id: str, intent_type: str) -> None: - """Record that an intent was sent to the execution layer. - - This is used for best-effort inflight handling. hftbacktest provides snapshots - (status/req) rather than explicit ACK events, so inflight is cleared heuristically - as soon as subsequent snapshots indicate completion. - - Compatibility boundary: - - This mutates internal execution-control tracking only. - - For ``intent_type == "new"``, it seeds an internal canonical order - projection at ``submitted`` as sidecar projection state. - - It does not create a canonical Event Stream record. - """ - + """Record that an intent was sent to the execution layer.""" bucket = self.last_sent_intents.get(instrument) if bucket is None: bucket = {} @@ -247,7 +158,6 @@ def mark_intent_sent(self, instrument: str, client_order_id: str, intent_type: s if inflight_bucket is None: inflight_bucket = {} self.inflight[instrument] = inflight_bucket - inflight_bucket[client_order_id] = InflightInfo(action=intent_type, ts_sent_ns_local=ts_now) if intent_type != "new": @@ -256,7 +166,6 @@ def mark_intent_sent(self, instrument: str, client_order_id: str, intent_type: s key = (instrument, client_order_id) if key in self.canonical_orders: return - self.canonical_orders[key] = CanonicalOrderProjection( instrument=instrument, client_order_id=client_order_id, @@ -266,37 +175,46 @@ def mark_intent_sent(self, instrument: str, client_order_id: str, intent_type: s ) def apply_order_submitted_event(self, event: OrderSubmittedEvent) -> None: - """Reduce canonical dispatch-time submitted entry into lifecycle projection. - - This reducer updates only internal canonical lifecycle projection state. - It intentionally does not mutate compatibility snapshot orders, inflight - bookkeeping, or last-sent tracking. - """ + """Reduce canonical submitted-entry into active-order projections.""" + self.update_timestamp(event.ts_ns_local_dispatch) key = (event.instrument, event.client_order_id) projection = self.canonical_orders.get(key) if projection is None: - self.canonical_orders[key] = CanonicalOrderProjection( + projection = CanonicalOrderProjection( instrument=event.instrument, client_order_id=event.client_order_id, state="submitted", submitted_ts_ns_local=event.ts_ns_local_dispatch, updated_ts_ns_local=event.ts_ns_local_dispatch, ) - return + self.canonical_orders[key] = projection - # Idempotent submitted-entry behavior: - # - If already submitted, keep existing projection unchanged. - # - If already beyond submitted, do not regress lifecycle state. - return + projection.state = "submitted" + projection.updated_ts_ns_local = max( + projection.updated_ts_ns_local, event.ts_ns_local_dispatch + ) + projection.side = event.side + projection.intended_price = event.intended_price.value + projection.intended_qty = event.intended_qty.value - def apply_control_time_event(self, event: ControlTimeEvent) -> None: - """Reduce canonical control-time event at boundary without state mutation. + order_bucket = self.orders.setdefault(event.instrument, {}) + order_bucket[event.client_order_id] = WorkingOrder( + instrument=event.instrument, + client_order_id=event.client_order_id, + side=event.side, + intended_price=event.intended_price.value, + intended_qty=event.intended_qty.value, + cum_filled_qty=0.0, + remaining_qty=event.intended_qty.value, + state="submitted", + submitted_ts_ns_local=event.ts_ns_local_dispatch, + updated_ts_ns_local=event.ts_ns_local_dispatch, + ) + self._clear_inflight(event.instrument, event.client_order_id) - This first core-only slice intentionally keeps ControlTimeEvent reduction - as a no-op for queue/rate/inflight and compatibility projections. - """ - _ = event - return + def apply_control_time_event(self, event: ControlTimeEvent) -> None: + """Reduce canonical control-time Event without side effects.""" + self.update_timestamp(event.ts_ns_local_control) def _clear_inflight(self, instrument: str, client_order_id: str) -> None: inflight_bucket = self.inflight.get(instrument) @@ -305,67 +223,11 @@ def _clear_inflight(self, instrument: str, client_order_id: str) -> None: inflight_bucket.pop(client_order_id, None) def has_inflight(self, instrument: str, client_order_id: str) -> bool: - """Return True if an order id currently has an inflight marker.""" inflight_bucket = self.inflight.get(instrument) if inflight_bucket is None: return False return client_order_id in inflight_bucket - def _maybe_clear_inflight_from_snapshot(self, event: OrderStateEvent) -> None: - """Heuristically clear inflight markers based on snapshot state. - - This avoids leaking inflight entries when orders progress from pending to - working/terminal states. - """ - - # NOTE: - # Inflight clearing is heuristic because the venue provides snapshots, - # not explicit ACK / completion events. - # This is sufficient for backtest and research purposes, but does NOT - # guarantee exact ACK ordering as in a live FIX/WebSocket venue. - - inflight_bucket = self.inflight.get(event.instrument) - if inflight_bucket is None: - return - - info = inflight_bucket.get(event.client_order_id) - if info is None: - return - - if event.ts_ns_local < info.ts_sent_ns_local: - return - - req_val = 0 - if isinstance(event.raw, dict): - req_val = event.raw.get("req", 0) - - # If the snapshot indicates no active request, clear inflight as soon - # as the snapshot is at or after the send time. - if req_val == 0: - if event.state_type in TERMINAL_ORDER_STATES or event.state_type == "rejected": - self._clear_inflight(event.instrument, event.client_order_id) - return - - if event.state_type in ("accepted", "working", "partially_filled"): - self._clear_inflight(event.instrument, event.client_order_id) - return - - if event.state_type in TERMINAL_ORDER_STATES or event.state_type == "rejected": - self._clear_inflight(event.instrument, event.client_order_id) - return - - if info.action == "new" and event.state_type in ("accepted", "working", "partially_filled"): - self._clear_inflight(event.instrument, event.client_order_id) - return - - if info.action == "cancel" and event.state_type == "canceled": - self._clear_inflight(event.instrument, event.client_order_id) - return - - if info.action == "replace" and event.state_type in ("working", "partially_filled"): - self._clear_inflight(event.instrument, event.client_order_id) - - # ---- Market ---- def update_market( self, instrument: str, @@ -380,29 +242,16 @@ def update_market( ts_ns_local: int, ts_ns_exch: int, ) -> None: - """Low-level market reducer primitive. - - This method applies a market snapshot update directly to internal state. - It is intentionally preserved for compatibility and reducer-level tests. - For canonical candidates, prefer ``process_canonical_event`` as the - top-level canonical ingestion boundary. - """ m = self.market.get(instrument) if m is None: m = MarketState() self.market[instrument] = m - - # Replacement-style update policy: - # - primary sort key: receipt time (local) - # - tie-breaker: venue time (best-effort) if ts_ns_local < m.last_ts_ns_local: return if ts_ns_local == m.last_ts_ns_local and ts_ns_exch <= m.last_ts_ns_exch: return - m.last_ts_ns_local = ts_ns_local m.last_ts_ns_exch = ts_ns_exch - m.best_bid = best_bid m.best_ask = best_ask m.best_bid_qty = best_bid_qty @@ -410,11 +259,8 @@ def update_market( m.tick_size = tick_size m.lot_size = lot_size m.contract_size = contract_size - - if m.best_bid > 0.0 and m.best_ask > 0.0: - m.mid = 0.5 * (m.best_bid + m.best_ask) - else: - m.mid = 0.0 + m.mid = 0.5 * (m.best_bid + m.best_ask) if m.best_bid > 0.0 and m.best_ask > 0.0 else 0.0 + self.update_timestamp(ts_ns_local) def _update_market_from_positioned_canonical_event( self, @@ -430,20 +276,12 @@ def _update_market_from_positioned_canonical_event( ts_ns_local: int, ts_ns_exch: int, ) -> None: - """Apply market update for a positioned canonical event. - - This helper intentionally bypasses timestamp replacement no-op rules. - It is valid only when called after canonical boundary ProcessingPosition - monotonicity validation has accepted the event as the next causal input. - """ m = self.market.get(instrument) if m is None: m = MarketState() self.market[instrument] = m - m.last_ts_ns_local = ts_ns_local m.last_ts_ns_exch = ts_ns_exch - m.best_bid = best_bid m.best_ask = best_ask m.best_bid_qty = best_bid_qty @@ -451,11 +289,8 @@ def _update_market_from_positioned_canonical_event( m.tick_size = tick_size m.lot_size = lot_size m.contract_size = contract_size - - if m.best_bid > 0.0 and m.best_ask > 0.0: - m.mid = 0.5 * (m.best_bid + m.best_ask) - else: - m.mid = 0.0 + m.mid = 0.5 * (m.best_bid + m.best_ask) if m.best_bid > 0.0 and m.best_ask > 0.0 else 0.0 + self.update_timestamp(ts_ns_local) def get_mid(self, instrument: str) -> float: m = self.market.get(instrument) @@ -473,7 +308,6 @@ def get_lot_size(self, instrument: str) -> float: m = self.market.get(instrument) return 0.0 if m is None else m.lot_size - # ---- Account ---- def update_account( self, instrument: str, @@ -488,7 +322,6 @@ def update_account( if a is None: a = AccountState() self.account[instrument] = a - a.position = position a.balance = balance a.fee = fee @@ -498,67 +331,22 @@ def update_account( mid = self.get_mid(instrument) a.equity = a.balance + a.position * mid * self.get_contract_size(instrument) - if a.initial_equity == 0.0 and mid > 0.0: a.initial_equity = a.equity - a.realized_pnl = (a.equity - a.initial_equity) if a.initial_equity != 0.0 else 0.0 - self._update_rolling_equity(ts_ns_local=self.last_ts_ns_local) - # ---- Derived Realized PnL detection ---- - last = self._last_realized_pnl.get(instrument) - cur = a.realized_pnl - - if last is None: - # First observation: initialize baseline, no event - self._last_realized_pnl[instrument] = cur - elif cur != last: - self._event_bus.emit( - DerivedPnLEvent( - ts_ns_local=self.last_ts_ns_local, - instrument=instrument, - delta_pnl=cur - last, - cum_realized_pnl=cur, - ) - ) - self._last_realized_pnl[instrument] = cur - - # ---- Derived Exposure detection ---- - mid = self.get_mid(instrument) - contract_size = self.get_contract_size(instrument) - exposure = a.position * mid * contract_size - - last_exposure = self._last_exposure.get(instrument) - - if last_exposure is None: - # First observation establishes baseline - self._last_exposure[instrument] = exposure - elif exposure != last_exposure: - self._event_bus.emit( - ExposureDerivedEvent( - ts_ns_local=self.last_ts_ns_local, - instrument=instrument, - exposure=exposure, - delta_exposure=exposure - last_exposure, - ) - ) - self._last_exposure[instrument] = exposure - def _update_rolling_equity(self, *, ts_ns_local: int) -> None: if ts_ns_local <= 0: return - total_equity = sum(x.equity for x in self.account.values()) dq = self.rolling_equity - if dq: last_ts, last_eq = dq[-1] if ts_ns_local < last_ts: return if ts_ns_local == last_ts and total_equity == last_eq: return - dq.append((ts_ns_local, total_equity)) def get_total_equity(self) -> float: @@ -567,542 +355,127 @@ def get_total_equity(self) -> float: def get_rolling_loss(self, *, now_ts_ns_local: int, window_ns: int) -> float | None: if window_ns <= 0: return None - dq = self.rolling_equity if not dq: dq.append((now_ts_ns_local, self.get_total_equity())) return 0.0 - cutoff = now_ts_ns_local - window_ns while len(dq) > 1 and dq[0][0] < cutoff: dq.popleft() - start_ts, start_eq = dq[0] if start_ts > now_ts_ns_local: return None - cur_eq = self.get_total_equity() return float(cur_eq - start_eq) def get_total_pnl(self) -> float: return float(sum(a.realized_pnl for a in self.account.values())) - # ---- Orders ---- - @staticmethod - def _should_drop_transition_update(cur: OrderSnapshot, event: OrderStateEvent) -> bool: - """Return True if a late transition-style update should be ignored. - - Transition updates (live-style) must not be dropped purely because they - arrived late. They should only be dropped if they are idempotent and do - not advance the current snapshot. - - Accepted as progress: - - terminal state applied when current state is non-terminal - - increased cumulative filled quantity - - decreased remaining quantity - - clearing a request marker (req!=0 -> req==0) - """ - - if event.state_type in TERMINAL_ORDER_STATES and cur.state_type not in TERMINAL_ORDER_STATES: - return False - - event_cum = 0.0 - if event.cum_filled_qty is not None: - event_cum = event.cum_filled_qty.value - - if event_cum > cur.cum_filled_qty: - return False - - event_remaining = 0.0 - if event.remaining_qty is not None: - event_remaining = event.remaining_qty.value - else: - intended_qty = event.intended_qty.value - event_remaining = max(0.0, intended_qty - event_cum) - - if event_remaining < cur.remaining_qty: - return False - - current_req = 0 - if isinstance(event.raw, dict): - try: - current_req = event.raw["req"] # type: ignore[arg-type] - except KeyError: - current_req = 0 - - if cur.req != current_req and current_req == 0: - return False - - # Idempotent late update: ignore when it is strictly older. - if event.ts_ns_local < cur.ts_ns_local: - return True - if event.ts_ns_local == cur.ts_ns_local and event.ts_ns_exch < cur.ts_ns_exch: - return True - - # Equal/newer but not progressing: keep latest by timestamp. - return False - - def apply_order_state_event(self, event: OrderStateEvent) -> None: - """Reduce compatibility execution-feedback into snapshot-facing state. - - This is the compatibility reducer path for ``OrderStateEvent`` records. - It is not canonical Event Stream processing. Internal canonical-order - projection updates performed here are sidecar projection logic used to - keep compatibility pathways aligned with submitted-boundary semantics. - """ - self._advance_canonical_order_projection(event) - - events_bucket = self.order_events.setdefault(event.instrument, deque()) - bucket = self.orders.setdefault(event.instrument, {}) - cur = bucket.get(event.client_order_id) - - raw_dict: dict[str, object | None] = event.raw if isinstance(event.raw, dict) else None - - current_req = 0 - source = "transition" - if raw_dict is not None: - try: - current_req = raw_dict["req"] # type: ignore[arg-type] - except KeyError: - current_req = 0 - - try: - source = raw_dict["source"] # type: ignore[arg-type] - except KeyError: - source = "transition" - - prev_req = cur.req if cur is not None else 0 - - inflight_bucket = self.inflight.get(event.instrument) - inflight_info = None if inflight_bucket is None else inflight_bucket.get(event.client_order_id) - - if ( - inflight_info is not None - and inflight_info.action == "replace" - and prev_req != 0 - and current_req == 0 - and event.state_type not in TERMINAL_ORDER_STATES - and event.state_type != "rejected" - ): - replaced_event = event.model_copy(update={"state_type": "replaced"}) - events_bucket.append(replaced_event) - - if cur is not None: - # Late-update policy - # - # - Replacement-style events ("snapshot") may be safely treated as - # overwrites. Older snapshots must not overwrite newer snapshots. - # - Transition-style events ("transition") should not be dropped - # purely because they are late. A late terminal update or a late - # fill progression must still be applied. - is_snapshot = source == "snapshot" - - if is_snapshot: - if event.ts_ns_local < cur.ts_ns_local: - return - if event.ts_ns_local == cur.ts_ns_local and event.ts_ns_exch < cur.ts_ns_exch: - return - else: - if self._should_drop_transition_update(cur, event): - return - - # Treat 'replaced' as an edge event. The order identity remains and the - # snapshot should continue to exist in state. - if event.state_type == "replaced": - effective_state = "working" if cur is None else cur.state_type - event = event.model_copy(update={"state_type": effective_state}) - - # Order lifecycle state transition validation (observability only) - prev_state: str | None = None if cur is None else cur.state_type - next_state: str = event.state_type - - if not is_valid_transition(prev_state, next_state): - self._event_bus.emit( - OrderStateTransitionEvent( - ts_ns_local=event.ts_ns_local, - instrument=event.instrument, - client_order_id=event.client_order_id, - prev_state=prev_state, - next_state=next_state, - ) - ) - - # Derived Fill detection (snapshot-based) - if ( - cur is not None - and event.cum_filled_qty is not None - ): - prev_cum = cur.cum_filled_qty - new_cum = event.cum_filled_qty.value - - if new_cum > prev_cum: - self._event_bus.emit( - DerivedFillEvent( - ts_ns_local=event.ts_ns_local, - instrument=event.instrument, - client_order_id=event.client_order_id, - side=event.side, - delta_qty=new_cum - prev_cum, - cum_qty=new_cum, - price=( - event.filled_price.value - if event.filled_price is not None - else None - ), - ) - ) - - events_bucket.append(event) - - intended_price = event.intended_price.value - filled_price = event.filled_price.value if event.filled_price is not None else 0.0 - - intended_qty = event.intended_qty.value - cum_filled_qty = event.cum_filled_qty.value if event.cum_filled_qty is not None else 0.0 - remaining_qty = ( - event.remaining_qty.value - if event.remaining_qty is not None - else max(0.0, intended_qty - cum_filled_qty) - ) - - snap = OrderSnapshot( - instrument=event.instrument, - client_order_id=event.client_order_id, - ts_ns_exch=event.ts_ns_exch, - ts_ns_local=event.ts_ns_local, - order_type=event.order_type, - time_in_force=event.time_in_force, - state_type=event.state_type, - side=event.side, - intended_price=intended_price, - filled_price=filled_price, - intended_qty=intended_qty, - cum_filled_qty=cum_filled_qty, - remaining_qty=remaining_qty, - req=current_req, - ) - - # Clear inflight heuristically before any early return. - self._maybe_clear_inflight_from_snapshot(event) - - if snap.state_type in TERMINAL_ORDER_STATES: - bucket.pop(event.client_order_id, None) - self._clear_inflight(event.instrument, event.client_order_id) - last_bucket = self.last_sent_intents.get(event.instrument) - if last_bucket is not None: - last_bucket.pop(event.client_order_id, None) - return - - bucket[event.client_order_id] = snap - - def _advance_canonical_order_projection(self, event: OrderStateEvent) -> None: - key = (event.instrument, event.client_order_id) - projection = self.canonical_orders.get(key) - if projection is None: - return - - next_canonical_state = normalize_compatibility_state_to_canonical(event.state_type) - if next_canonical_state is None: - return - if event.ts_ns_local < projection.updated_ts_ns_local: - return - if not is_valid_canonical_order_transition(projection.state, next_canonical_state): - return - - projection.state = next_canonical_state - projection.updated_ts_ns_local = event.ts_ns_local - - # ---- Fills ---- - - # NOTE: - # Currently unused. - # hftbacktest does not emit explicit FillEvent deltas; fills are inferred - # indirectly from order state snapshots instead. - # This method is reserved for event-driven backends or live trading venues - # that provide fill-level events. def apply_fill_event(self, event: FillEvent, *, max_keep: int = 10_000) -> None: - """Low-level fill reducer primitive. - - This method applies fill deltas directly to internal state and emits the - fill event on the bus. It remains available for compatibility and - reducer-level parity testing. For canonical candidates, prefer - ``process_canonical_event`` as the top-level canonical ingestion - boundary. - """ + """Reduce canonical fill deltas into fill and active-order projections.""" + self.update_timestamp(event.ts_ns_local) instrument = event.instrument client_order_id = event.client_order_id - bucket = self.fill_cum_qty[instrument] if instrument in self.fill_cum_qty else None - if bucket is None: - bucket = {} - self.fill_cum_qty[instrument] = bucket - + bucket = self.fill_cum_qty.setdefault(instrument, {}) cum_qty = event.cum_filled_qty.value - last_cum: float = bucket[client_order_id] if client_order_id in bucket else None - if last_cum is not None: - # Fill events are deltas. Duplicates commonly repeat the same cumulative filled. - # Late/out-of-order fills can arrive with a smaller cumulative filled. - # Both cases should be idempotent no-ops. - if cum_qty <= last_cum + 1e-12: - return - + last_cum = bucket.get(client_order_id) + if last_cum is not None and cum_qty <= last_cum + 1e-12: + return bucket[client_order_id] = cum_qty - dq = self.fills[instrument] if instrument in self.fills else None - if dq is None: - dq = deque() - self.fills[instrument] = dq - + dq = self.fills.setdefault(instrument, deque()) dq.append(event) while len(dq) > max_keep: dq.popleft() - self._event_bus.emit(event) - - def ingest_order_snapshots(self, instrument: str, orders_snapshot_iter: Iterable[object]) -> None: - """Ingest hftbacktest order snapshots and reduce them into internal state. - - hftbacktest provides *snapshots* (not deltas). We translate each snapshot into - an OrderStateEvent (snapshot) and feed it into apply_order_state_event(). - - This is an adapter/materialization path for compatibility snapshot - ingestion. It is intentionally separate from canonical ingestion. - """ - - def map_status(status: int, req: int, client_order_id: str) -> str: - """Best-effort mapping from hftbacktest (status, req) to schema state. - - Design: terminal status wins. If a request marker is present (req!=0), - treat this as "in-flight". In that case, "pending_new" is used only - for in-flight NEW actions; all other in-flight actions map to "accepted". - """ - - if status == 3: - return "filled" - if status == 4: - return "canceled" - if status == 5: - return "expired" - - if req != 0: - inflight_bucket = self.inflight.get(instrument) - inflight_info = None if inflight_bucket is None else inflight_bucket.get(client_order_id) - if inflight_info is not None and inflight_info.action == "new": - return "pending_new" - return "accepted" - - if status == 0: - return "accepted" - if status == 1: - return "working" - if status == 2: - return "partially_filled" - - return "rejected" - - # hftbacktest "values()" often returns a custom iterator with has_next/get - if hasattr(orders_snapshot_iter, "has_next") and hasattr(orders_snapshot_iter, "get"): - it = orders_snapshot_iter - - def _next() -> object | None: - return it.get() if it.has_next() else None - - while True: - o = _next() - if o is None: - break - self._ingest_one_hft_order(instrument, o, map_status) - return + order_bucket = self.orders.get(instrument) + if order_bucket is not None: + working = order_bucket.get(client_order_id) + if working is not None: + working.cum_filled_qty = cum_qty + if event.remaining_qty is not None: + working.remaining_qty = event.remaining_qty.value + else: + working.remaining_qty = max(0.0, working.intended_qty - cum_qty) + working.state = "filled" if working.remaining_qty <= 1e-12 else "partially_filled" + working.updated_ts_ns_local = event.ts_ns_local + if working.state == "filled": + order_bucket.pop(client_order_id, None) + self._clear_inflight(instrument, client_order_id) + + projection = self.canonical_orders.get((instrument, client_order_id)) + if projection is not None and event.ts_ns_local >= projection.updated_ts_ns_local: + if event.remaining_qty is not None and event.remaining_qty.value <= 1e-12: + projection.state = "filled" + else: + projection.state = "partially_filled" + projection.updated_ts_ns_local = event.ts_ns_local - # Otherwise assume a normal Python iterable - for o in orders_snapshot_iter: - self._ingest_one_hft_order(instrument, o, map_status) + self._event_bus.emit(event) - def _ingest_one_hft_order( + def apply_order_execution_feedback_event( self, - instrument: str, - o: object, - map_status: Callable[[int, int, int], str], + event: OrderExecutionFeedbackEvent, ) -> None: - """Translate a single hftbacktest order snapshot object into an OrderStateEvent.""" - - # --- Map primitive enums to your schema enums --- - order_type: str = "limit" if o.order_type == 0 else "market" - - # hftbacktest typically uses BUY=1, SELL=-1 - side: str = "buy" if o.side == 1 else "sell" - - tif: int = o.time_in_force - if tif == 0: - time_in_force = "GTC" - elif tif == 1: - time_in_force = "IOC" - elif tif == 2: - time_in_force = "FOK" - elif tif == 3: - time_in_force = "POST_ONLY" - else: - time_in_force = "GTC" - - req_val: int = o.req - - state_type: str = map_status(o.status, req_val, o.order_id) - - # --- Prices / quantities (schema requires structured objects) --- - intended_price: dict[str, str | float] = {"currency": _UNKNOWN_CCY, "value": o.price} - - exec_price: float = o.exec_price - filled_price: dict[str, str | float] = None if exec_price <= 0.0 else {"currency": _UNKNOWN_CCY, "value": exec_price} - - intended_qty: dict[str, str | float] = {"value": o.qty, "unit": _DEFAULT_QTY_UNIT} - - exec_qty: float = o.exec_qty - cum_filled_qty: dict[str, str | float] = None if exec_qty <= 0.0 else {"value": exec_qty, "unit": _DEFAULT_QTY_UNIT} - - leaves_qty: float = o.leaves_qty - remaining_qty: dict[str, str | float] = {"value": leaves_qty, "unit": _DEFAULT_QTY_UNIT} - - event = OrderStateEvent( - ts_ns_exch=o.exch_timestamp, - ts_ns_local=o.local_timestamp, - instrument=instrument, - client_order_id=str(o.order_id), - order_type=order_type, - state_type=state_type, - side=side, - intended_price=intended_price, - filled_price=filled_price, - intended_qty=intended_qty, - cum_filled_qty=cum_filled_qty, - remaining_qty=remaining_qty, - time_in_force=time_in_force, - reason=None, - raw={"status": o.status, "req": req_val, "source": "snapshot"}, + """Reduce canonical execution feedback into account state only.""" + self.update_timestamp(event.ts_ns_local_feedback) + self.update_account( + instrument=event.instrument, + position=event.position, + balance=event.balance, + fee=event.fee, + trading_volume=event.trading_volume, + trading_value=event.trading_value, + num_trades=event.num_trades, ) - self.apply_order_state_event(event) - - def get_orders(self, instrument: str) -> dict[str, OrderSnapshot]: - """Return active order snapshots for an instrument (read-only view).""" - return self.orders.get(instrument, {}) - - # NOTE: - # Currently unused. - # OrderStateEvents are accumulated for observability (e.g. replaced, invalid - # transitions), but no strategy currently consumes edge-events explicitly. - # This hook is reserved for strategies that require order lifecycle events. - def pop_order_events(self, instrument: str) -> list[OrderStateEvent]: - """Return and clear accumulated OrderStateEvents for an instrument. - - The engine calls the strategy without passing a per-event stream. Strategies - that need edge-events (e.g. replaced) can consume them via this method. - """ - - dq = self.order_events.get(instrument) - if dq is None or not dq: - return [] - - out: list[OrderStateEvent] = list(dq) - dq.clear() - return out - - def get_working_order_snapshot(self, instrument: str, client_order_id: str) -> OrderSnapshot | None: - """Return an active order snapshot for an order id. - - Returns None if no active snapshot exists. - """ - + def get_working_order_snapshot(self, instrument: str, client_order_id: str) -> WorkingOrder | None: bucket = self.orders.get(instrument) if bucket is None: return None return bucket.get(client_order_id) - # ---- Slot helpers (multi-level quoting) ---- - def slot_client_order_id(self, slot: SlotKey, namespace: str) -> str: - """Return the stable client_order_id for a slot.""" - return stable_slot_order_id(slot, namespace=namespace) - - def is_slot_busy(self, slot: SlotKey, namespace: str) -> bool: - """Return True if a slot is busy in queued ∪ working.""" - - client_order_id = self.slot_client_order_id(slot, namespace=namespace) - return self.is_order_id_busy(slot.instrument, client_order_id) - - # ---- Queue / existence helpers (C1) ---- - - # NOTE: - # Currently unused. - # SlotKey helpers are intended for slot-based / multi-level market making - # strategies with deterministic order identifiers. - # No slot-based strategy is implemented at this time. - def slot_key(self, instrument: str, side: str, level_index: int) -> SlotKey: - """Create a SlotKey for a given instrument/side/level.""" - - return SlotKey(instrument=str(instrument), side=str(side), level_index=int(level_index)) - - # NOTE: - # Currently unused. - # Deterministic slot-based order IDs are reserved for slot-driven quoting - # strategies. Current strategies generate order IDs explicitly. - def slot_order_id(self, slot: SlotKey, namespace: str) -> str: - """Return the deterministic client_order_id for a slot.""" - - return stable_slot_order_id(slot, namespace=namespace) - - def is_order_id_busy(self, instrument: str, client_order_id: str) -> bool: - """Return True if an order id exists in queued ∪ working.""" - - return bool( - self.has_working_order(instrument, client_order_id) - or self.has_queued_intent(instrument, client_order_id) - ) - - # NOTE: - # Currently unused. - # Slot occupancy checks are only relevant for slot-based strategies - # that enforce one active order per slot. - def is_slot_key_busy(self, slot: SlotKey, namespace: str) -> bool: - """Return True if a slot has a working order or queued intent.""" - - order_id = stable_slot_order_id(slot, namespace=namespace) - return self.is_order_id_busy(slot.instrument, order_id) - def has_working_order(self, instrument: str, client_order_id: str) -> bool: - """Check whether an active (non-terminal) order exists in working state.""" bucket = self.orders.get(instrument) if bucket is None: return False return client_order_id in bucket def has_queued_intent(self, instrument: str, client_order_id: str) -> bool: - """Check whether any queued intent exists for the given order id.""" q = self.queued_intents.get(instrument) if q is None: return False key = f"order:{client_order_id}" return any(qi.logical_key == key for qi in q) + def queued_intents_snapshot(self, instrument: str | None = None) -> tuple[OrderIntent, ...]: + if instrument is not None: + q = self.queued_intents.get(instrument) + if q is None: + return () + return tuple(qi.intent for qi in q) + snapshots: list[OrderIntent] = [] + for instrument_key in sorted(self.queued_intents): + snapshots.extend(qi.intent for qi in self.queued_intents[instrument_key]) + return tuple(snapshots) + def pop_queued_intents_for_order(self, instrument: str, client_order_id: str) -> list[QueuedIntent]: - """Remove and return all queued intents for the given order id.""" q = self.queued_intents.get(instrument) if q is None or not q: return [] - key = f"order:{client_order_id}" removed: list[QueuedIntent] = [] kept: deque[QueuedIntent] = deque() - for qi in q: if qi.logical_key == key: removed.append(qi) else: kept.append(qi) - self.queued_intents[instrument] = kept return removed def find_queued_new_intent(self, instrument: str, client_order_id: str) -> NewOrderIntent | None: - """Return the queued NEW intent for the given order id, if present.""" q = self.queued_intents.get(instrument) if q is None: return None @@ -1113,7 +486,6 @@ def find_queued_new_intent(self, instrument: str, client_order_id: str) -> NewOr return None def _intent_priority(self, intent: OrderIntent) -> int: - """Lower number means higher priority for flushing.""" if intent.intent_type == "cancel": return 0 if intent.intent_type == "replace": @@ -1123,12 +495,6 @@ def _intent_priority(self, intent: OrderIntent) -> int: return 9 def _compute_logical_key(self, intent: OrderIntent) -> str: - """Compute a stable key for queue replacement/deduplication. - - Contract: - - All order lifecycle operations are keyed by client_order_id. - - flags/target identifiers are intentionally not supported. - """ return f"order:{intent.client_order_id}" def merge_intents_into_queue( @@ -1136,45 +502,26 @@ def merge_intents_into_queue( instrument: str, intents: Iterable[OrderIntent], ) -> tuple[list[OrderIntent], list[tuple[OrderIntent, OrderIntent]], list[OrderIntent]]: - """Merge intents into the outbox queue with replacement semantics. - - This is OUTBOX DATA management only (no policy). The Risk/Gate decides what goes here. - - Replacement rules per logical_key: - - CANCEL dominates: - remove any queued NEW/REPLACE for that key and queue only CANCEL. - if CANCEL already queued, additional CANCEL replaces the older CANCEL (keep latest). - - REPLACE replaces queued NEW/REPLACE for that key. - if CANCEL is queued, the REPLACE is dropped (cancel dominates). - - NEW replaces queued NEW for that key. - if REPLACE or CANCEL is queued, the NEW is dropped (new is obsolete). - """ q: deque[QueuedIntent] = self.queued_intents.setdefault(instrument, deque()) - queued: list[OrderIntent] = [] replaced_in_queue: list[tuple[OrderIntent, OrderIntent]] = [] dropped: list[OrderIntent] = [] - # Helper: find all queued entries matching key def _matching_entries(key: str) -> list[QueuedIntent]: return [qi for qi in q if qi.logical_key == key] for intent in intents: key = self._compute_logical_key(intent) prio = self._intent_priority(intent) - matches = _matching_entries(key) - has_cancel = any(qi.intent.intent_type == "cancel" for qi in matches) has_replace = any(qi.intent.intent_type == "replace" for qi in matches) has_new = any(qi.intent.intent_type == "new" for qi in matches) if intent.intent_type == "cancel": - # Remove all existing entries for the key (including older cancel) and keep latest cancel only. for qi in list(matches): q.remove(qi) replaced_in_queue.append((qi.intent, intent)) - q.append( QueuedIntent( intent=intent, @@ -1187,17 +534,13 @@ def _matching_entries(key: str) -> list[QueuedIntent]: continue if intent.intent_type == "replace": - # If a cancel is already queued, replace is obsolete. if has_cancel: dropped.append(intent) continue - - # Remove queued NEW/REPLACE for that key, keep only latest replace. for qi in list(matches): if qi.intent.intent_type in ("new", "replace"): q.remove(qi) replaced_in_queue.append((qi.intent, intent)) - q.append( QueuedIntent( intent=intent, @@ -1210,18 +553,14 @@ def _matching_entries(key: str) -> list[QueuedIntent]: continue if intent.intent_type == "new": - # If cancel or replace is already queued, new is obsolete. if has_cancel or has_replace: dropped.append(intent) continue - - # Replace only queued NEW for that key (keep latest new). if has_new: for qi in list(matches): if qi.intent.intent_type == "new": q.remove(qi) replaced_in_queue.append((qi.intent, intent)) - q.append( QueuedIntent( intent=intent, @@ -1233,7 +572,6 @@ def _matching_entries(key: str) -> list[QueuedIntent]: queued.append(intent) continue - # Unknown intent types are dropped to avoid silent weirdness. dropped.append(intent) return queued, replaced_in_queue, dropped @@ -1244,48 +582,29 @@ def pop_queued_intents( *, max_items: int | None = None, ) -> list[OrderIntent]: - """Pop flush candidates from the outbox queue. - - Ordering: - - priority (cancel -> replace -> new) - - FIFO by queued_at_ts_ns within same priority - - This function removes the selected items from the queue and returns their intents. - Gate may decide to re-queue them again if still rate-limited. - """ q: deque[QueuedIntent] = self.queued_intents.setdefault(instrument, deque()) if not q: return [] - items = list(q) items.sort(key=lambda qi: (qi.priority, qi.queued_at_ts_ns)) - # Inflight gating: do not emit intents for order ids that currently have - # an outbound request in flight. This keeps the queue stable and avoids - # sending replace storms while ACKs are pending. filtered: list[QueuedIntent] = [] for qi in items: if self.has_inflight(instrument, qi.intent.client_order_id): continue filtered.append(qi) - if max_items is None: - selected = filtered - else: - if max_items <= 0: - return [] - selected = filtered[:max_items] + selected = filtered if max_items is None else filtered[:max(0, max_items)] + if max_items is not None and max_items <= 0: + return [] selected_ids = {id(x) for x in selected} - out: list[OrderIntent] = [] new_q: deque[QueuedIntent] = deque() - for qi in q: if id(qi) in selected_ids: out.append(qi.intent) else: new_q.append(qi) - self.queued_intents[instrument] = new_q return out diff --git a/tradingchassis_core/core/domain/step_decision.py b/tradingchassis_core/core/domain/step_decision.py new file mode 100644 index 0000000..e06fdec --- /dev/null +++ b/tradingchassis_core/core/domain/step_decision.py @@ -0,0 +1,51 @@ +"""Core-owned non-canonical decision scaffold for run_core_step results.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from tradingchassis_core.core.domain.execution_control_decision import ( + ExecutionControlDecision, +) +from tradingchassis_core.core.domain.policy_risk_decision import PolicyRiskDecision +from tradingchassis_core.core.domain.types import OrderIntent +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation + + +@dataclass(frozen=True, slots=True) +class CoreStepDecision: + """Immutable scaffold decision model for integrated Core-step semantics.""" + + policy_rejected_intents: tuple[OrderIntent, ...] = () + policy_risk_decision: PolicyRiskDecision | None = None + execution_control_decision: ExecutionControlDecision | None = None + queued_effective_intents: tuple[OrderIntent, ...] = () + dispatchable_intents: tuple[OrderIntent, ...] = () + execution_handled_intents: tuple[OrderIntent, ...] = () + control_scheduling_obligation: ControlSchedulingObligation | None = None + + def __post_init__(self) -> None: + if not isinstance(self.policy_rejected_intents, tuple): + object.__setattr__( + self, + "policy_rejected_intents", + tuple(self.policy_rejected_intents), + ) + if not isinstance(self.queued_effective_intents, tuple): + object.__setattr__( + self, + "queued_effective_intents", + tuple(self.queued_effective_intents), + ) + if not isinstance(self.dispatchable_intents, tuple): + object.__setattr__( + self, + "dispatchable_intents", + tuple(self.dispatchable_intents), + ) + if not isinstance(self.execution_handled_intents, tuple): + object.__setattr__( + self, + "execution_handled_intents", + tuple(self.execution_handled_intents), + ) diff --git a/tradingchassis_core/core/domain/step_result.py b/tradingchassis_core/core/domain/step_result.py new file mode 100644 index 0000000..1d1e1d4 --- /dev/null +++ b/tradingchassis_core/core/domain/step_result.py @@ -0,0 +1,60 @@ +"""Core step result contract model.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from tradingchassis_core.core.domain.candidate_intent import CandidateIntentRecord +from tradingchassis_core.core.domain.step_decision import CoreStepDecision +from tradingchassis_core.core.domain.types import OrderIntent +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation + + +@dataclass(frozen=True, slots=True) +class CoreStepResult: + """Immutable result object for deterministic Core step APIs. + + ``control_scheduling_obligation`` is set only when Execution Control apply + defers for **rate limits** (time-dependent). It is ``None`` for inflight-only + deferral and other cases without a Core-derived wake time. Only injected + ``ControlTimeEvent`` values are canonical stream input for control time. + """ + + generated_intents: tuple[OrderIntent, ...] = () + candidate_intent_records: tuple[CandidateIntentRecord, ...] = () + candidate_intents: tuple[OrderIntent, ...] = () + dispatchable_intents: tuple[OrderIntent, ...] = () + control_scheduling_obligation: ControlSchedulingObligation | None = None + core_step_decision: CoreStepDecision | None = None + + def __post_init__(self) -> None: + if not isinstance(self.generated_intents, tuple): + object.__setattr__( + self, + "generated_intents", + tuple(self.generated_intents), + ) + if not isinstance(self.candidate_intent_records, tuple): + object.__setattr__( + self, + "candidate_intent_records", + tuple(self.candidate_intent_records), + ) + if not isinstance(self.candidate_intents, tuple): + object.__setattr__( + self, + "candidate_intents", + tuple(self.candidate_intents), + ) + if self.candidate_intent_records: + object.__setattr__( + self, + "candidate_intents", + tuple(record.intent for record in self.candidate_intent_records), + ) + if not isinstance(self.dispatchable_intents, tuple): + object.__setattr__( + self, + "dispatchable_intents", + tuple(self.dispatchable_intents), + ) diff --git a/tradingchassis_core/core/domain/types.py b/tradingchassis_core/core/domain/types.py index a368d3e..f3261cc 100644 --- a/tradingchassis_core/core/domain/types.py +++ b/tradingchassis_core/core/domain/types.py @@ -1,60 +1,37 @@ -"""Core shared data models and schemas. +"""Core shared data models. -This module defines Pydantic models used across the system for market data, -order intents, risk constraints, and execution feedback. - -Semantic notes for this refactor slice: -- ``MarketEvent`` is a canonical Market Event candidate. -- ``FillEvent`` is tracked as a canonical Execution Event candidate. -- ``OrderStateEvent`` remains a compatibility execution-feedback / - snapshot-materialization record for now. - -These models are treated as schema definitions and intentionally prioritize -structural clarity over minimal class size. +Pydantic models in this module are the source of truth for Core contracts. """ # pylint: disable=line-too-long,missing-class-docstring,missing-function-docstring from __future__ import annotations -from typing import Annotated, Any, Literal +from typing import Annotated, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator -# --------------------------------------------------------------------------- -# Common models -# --------------------------------------------------------------------------- - class Money(BaseModel): currency: str = Field(..., min_length=1) - amount: float = Field(...,) - + amount: float = Field(...) model_config = ConfigDict(extra="forbid") class Price(BaseModel): currency: str = Field(..., min_length=1) value: float = Field(..., ge=0) - model_config = ConfigDict(extra="forbid") class Quantity(BaseModel): value: float = Field(..., ge=0) - unit: str = Field(..., min_length=1) # e.g. "shares", "contracts", "BTC" - + unit: str = Field(..., min_length=1) model_config = ConfigDict(extra="forbid") -# --------------------------------------------------------------------------- -# Market data models (MarketEvent + payloads) -# --------------------------------------------------------------------------- - - class BookLevel(BaseModel): price: Price quantity: Quantity - model_config = ConfigDict(extra="forbid") @@ -62,9 +39,7 @@ class BookPayload(BaseModel): book_type: Literal["snapshot", "delta"] bids: list[BookLevel] asks: list[BookLevel] - # depth is optional in the JSON schema, but must be >= 0 when present depth: int | None = Field(default=None, ge=0) - model_config = ConfigDict(extra="forbid") @@ -73,29 +48,20 @@ class TradePayload(BaseModel): price: Price quantity: Quantity trade_id: str | None = Field(default=None, min_length=1) - model_config = ConfigDict(extra="forbid") class MarketEvent(BaseModel): ts_ns_exch: int = Field(..., gt=0) ts_ns_local: int = Field(..., gt=0) - instrument: str = Field(..., min_length=1) event_type: Literal["book", "trade"] - book: BookPayload | None = None trade: TradePayload | None = None - model_config = ConfigDict(extra="forbid") @model_validator(mode="after") def validate_payload_for_event_type(self) -> MarketEvent: - """ - Enforce the conditional requirements from the JSON schema: - - If event_type == "book": book must be present, trade must be None - - If event_type == "trade": trade must be present, book must be None - """ if self.event_type == "book": if self.book is None: raise ValueError("book payload is required when event_type is 'book'") @@ -115,146 +81,49 @@ def is_trade(self) -> bool: return self.event_type == "trade" -# --------------------------------------------------------------------------- -# Order intent models (discriminated union) -# --------------------------------------------------------------------------- - - TimeInForce = Literal["GTC", "IOC", "FOK", "POST_ONLY"] OrderType = Literal["limit", "market"] Side = Literal["buy", "sell"] class OrderIntentBase(BaseModel): - """ - Base fields shared by all order intents. - - Notes: - - client_order_id maps to the execution binding's order_id and is used for new/cancel/replace. - - intents_correlation_id is optional and can be used to link multiple intents together. - """ - - ts_ns_local: int = Field( - ..., - gt=0, - description="Local intent timestamp in nanoseconds since Unix epoch.", - ) - instrument: str = Field( - ..., - min_length=1, - description="Instrument identifier used for routing/execution binding (e.g., symbol, asset code).", - ) - client_order_id: str = Field( - ..., - min_length=1, - description=( - "Order identifier (maps to the execution binding's order_id). " - "Used for new/cancel/replace. Must be unique while an order with the same ID exists." - ), - ) - intents_correlation_id: str | None = Field( - default=None, - min_length=1, - description=( - "Optional correlation identifier to link multiple intents " - "(e.g., decision bundles) across the order lifecycle." - ), - ) - + ts_ns_local: int = Field(..., gt=0) + instrument: str = Field(..., min_length=1) + client_order_id: str = Field(..., min_length=1) + intents_correlation_id: str | None = Field(default=None, min_length=1) model_config = ConfigDict(extra="forbid") class NewOrderIntent(OrderIntentBase): - """ - Create a new order. - - Important: - - intended_price is required for both limit and market orders to match the execution binding signature. - """ - - intent_type: Literal["new"] = Field( - "new", - description="Intent type describing the order lifecycle action.", - ) - - side: Side = Field(..., description="Order side.") - order_type: OrderType = Field(..., description="Order type.") - intended_qty: Quantity = Field( - ..., - description="Intended total order quantity.", - ) - intended_price: Price = Field( - ..., - description=( - "Intended order price. Required for both limit and market orders " - "to match the execution binding signature." - ), - ) - time_in_force: TimeInForce = Field( - ..., - description="Time in force. Required for new intents.", - ) + intent_type: Literal["new"] = Field("new") + side: Side = Field(...) + order_type: OrderType = Field(...) + intended_qty: Quantity = Field(...) + intended_price: Price = Field(...) + time_in_force: TimeInForce = Field(...) class CancelOrderIntent(OrderIntentBase): - """ - Cancel an existing order identified by client_order_id. + intent_type: Literal["cancel"] = Field("cancel") - This intent deliberately forbids order-creation fields (side, order_type, qty, price, tif). - """ - intent_type: Literal["cancel"] = Field( - "cancel", - description="Intent type describing the order lifecycle action.", - ) +class ReplaceOrderIntent(OrderIntentBase): + intent_type: Literal["replace"] = Field("replace") + side: Side = Field(...) + order_type: Literal["limit"] = Field("limit") + intended_qty: Quantity = Field(...) + intended_price: Price = Field(...) -class ReplaceOrderIntent(OrderIntentBase): - """ - Modify an existing order (limit-only). The order ID remains the same (client_order_id). - - Notes: - - order_type is constrained to 'limit'. - - time_in_force is intentionally not present here because the execution binding does not support modifying it. - - intended_qty is the new TOTAL quantity (not a delta). - """ - - intent_type: Literal["replace"] = Field( - "replace", - description="Intent type describing the order lifecycle action.", - ) - - side: Side = Field(..., description="Order side.") - order_type: Literal["limit"] = Field( - "limit", - description="Order type. For replace intents this must be 'limit'.", - ) - intended_qty: Quantity = Field( - ..., - description="Intended total order quantity (new total quantity, not a delta).", - ) - intended_price: Price = Field( - ..., - description="Intended order price.", - ) - - -# Discriminated union: Pydantic will select the correct model based on intent_type. OrderIntent = Annotated[ NewOrderIntent | CancelOrderIntent | ReplaceOrderIntent, Field(discriminator="intent_type"), ] -# --------------------------------------------------------------------------- -# Risk constraints models -# --------------------------------------------------------------------------- - - class PositionLimits(BaseModel): currency: str = Field(..., min_length=1) max_position: float | None = Field(default=None, ge=0) - model_config = ConfigDict(extra="forbid") @@ -262,7 +131,6 @@ class NotionalLimits(BaseModel): currency: str = Field(..., min_length=1) max_gross_notional: float | None = Field(default=None, ge=0) max_single_order_notional: float | None = Field(default=None, ge=0) - model_config = ConfigDict(extra="forbid") @@ -271,14 +139,12 @@ class QuoteLimits(BaseModel): max_gross_quote_notional: float | None = Field(default=None, ge=0) max_net_quote_notional: float | None = None max_active_quotes: int | None = Field(default=None, ge=0) - model_config = ConfigDict(extra="forbid") class OrderRateLimits(BaseModel): max_orders_per_second: float | None = Field(default=None, ge=0) max_cancels_per_second: float | None = Field(default=None, ge=0) - model_config = ConfigDict(extra="forbid") @@ -287,7 +153,6 @@ class MaxLoss(BaseModel): max_drawdown: float = Field(..., lt=0) rolling_loss: float | None = Field(default=None, lt=0) rolling_loss_window: float | None = Field(default=None, gt=0) - model_config = ConfigDict(extra="forbid") @@ -295,88 +160,68 @@ class RiskConstraints(BaseModel): ts_ns_local: int = Field(..., gt=0) scope: str = Field(..., min_length=1) trading_enabled: bool - position_limits: PositionLimits | None = None notional_limits: NotionalLimits | None = None quote_limits: QuoteLimits | None = None order_rate_limits: OrderRateLimits | None = None max_loss: MaxLoss | None = None - extra: dict[str, str | float | bool | None] = Field(default_factory=dict) - model_config = ConfigDict(extra="forbid") -# --------------------------------------------------------------------------- -# FillEvent model (delta event) -# --------------------------------------------------------------------------- - - class FillEvent(BaseModel): ts_ns_exch: int = Field(..., gt=0) ts_ns_local: int = Field(..., gt=0) - instrument: str = Field(..., min_length=1) client_order_id: str = Field(..., min_length=1) - side: Literal["buy", "sell"] intended_price: Price | None = None - filled_price: Price intended_qty: Quantity | None = None - cum_filled_qty: Quantity remaining_qty: Quantity | None = None - time_in_force: Literal["GTC", "IOC", "FOK", "POST_ONLY"] liquidity_flag: Literal["maker", "taker", "unknown"] - fee: Money | None = None - model_config = ConfigDict(extra="forbid") -# --------------------------------------------------------------------------- -# OrderSubmittedEvent model (dispatch-time submitted boundary event) -# --------------------------------------------------------------------------- +class OrderExecutionFeedbackEvent(BaseModel): + ts_ns_local_feedback: int = Field(..., gt=0) + instrument: str = Field(..., min_length=1) + position: float + balance: float + fee: float + trading_volume: float + trading_value: float + num_trades: int + runtime_correlation: dict[str, str | int | float | bool | None] | None = None + model_config = ConfigDict(extra="forbid") class OrderSubmittedEvent(BaseModel): ts_ns_local_dispatch: int = Field(..., gt=0) - instrument: str = Field(..., min_length=1) client_order_id: str = Field(..., min_length=1) - side: Literal["buy", "sell"] order_type: Literal["limit", "market"] - intended_price: Price intended_qty: Quantity time_in_force: Literal["GTC", "IOC", "FOK", "POST_ONLY"] - intent_correlation_id: str | None = Field(default=None, min_length=1) dispatch_attempt_id: str | None = Field(default=None, min_length=1) runtime_correlation: dict[str, str | int | float | bool | None] | None = None - model_config = ConfigDict(extra="forbid") -# --------------------------------------------------------------------------- -# ControlTimeEvent model (runtime-realized control-time canonical event) -# --------------------------------------------------------------------------- - - class ControlTimeEvent(BaseModel): ts_ns_local_control: int = Field(..., gt=0) reason: str = Field(..., min_length=1) - due_ts_ns_local: int | None = Field(default=None, gt=0) realized_ts_ns_local: int | None = Field(default=None, gt=0) - obligation_reason: str | None = Field(default=None, min_length=1) obligation_due_ts_ns_local: int | None = Field(default=None, gt=0) runtime_correlation: dict[str, str | int | float | bool | None] | None = None - model_config = ConfigDict(extra="forbid") @model_validator(mode="after") @@ -386,51 +231,3 @@ def validate_due_or_realized_present(self) -> ControlTimeEvent: "at least one of due_ts_ns_local or realized_ts_ns_local is required" ) return self - - -# --------------------------------------------------------------------------- -# OrderStateEvent model (snapshot event) -# --------------------------------------------------------------------------- - - -class OrderStateEvent(BaseModel): - """Compatibility execution-feedback / snapshot-materialization record. - - ``OrderStateEvent`` remains non-canonical in this slice. It exists for - compatibility ingestion/projection flows and must not be interpreted as a - canonical Event Stream record. - """ - ts_ns_exch: int = Field(..., gt=0) - ts_ns_local: int = Field(..., gt=0) - - instrument: str = Field(..., min_length=1) - client_order_id: str = Field(..., min_length=1) - - order_type: Literal["limit", "market"] - state_type: Literal[ - "pending_new", - "accepted", - "working", - "partially_filled", - "filled", - "canceled", - "expired", - "rejected", - "replaced", - ] - - side: Literal["buy", "sell"] - intended_price: Price - - filled_price: Price | None = None - intended_qty: Quantity - - cum_filled_qty: Quantity | None = None - remaining_qty: Quantity | None = None - - time_in_force: Literal["GTC", "IOC", "FOK", "POST_ONLY"] - - reason: str | None = Field(default=None, min_length=1) - raw: dict[str, Any | None] = None - - model_config = ConfigDict(extra="forbid") diff --git a/tradingchassis_core/core/events/event_bus.py b/tradingchassis_core/core/events/event_bus.py index 5cf493d..59eb396 100644 --- a/tradingchassis_core/core/events/event_bus.py +++ b/tradingchassis_core/core/events/event_bus.py @@ -25,7 +25,7 @@ def register(self, sink: EventSink) -> None: self._sinks.append(sink) def emit(self, event: Any) -> None: - """Emit an event to all sinks.""" + """Emit an Event to all sinks.""" for sink in self._sinks: sink.on_event(event) diff --git a/tradingchassis_core/core/events/event_sink.py b/tradingchassis_core/core/events/event_sink.py index 49a8b3d..3040040 100644 --- a/tradingchassis_core/core/events/event_sink.py +++ b/tradingchassis_core/core/events/event_sink.py @@ -10,4 +10,4 @@ class EventSink(Protocol): def on_event(self, event: Any) -> None: - """Consume a domain event.""" + """Consume a domain Event.""" diff --git a/tradingchassis_core/core/events/events.py b/tradingchassis_core/core/events/events.py index 4255fa5..36d8155 100644 --- a/tradingchassis_core/core/events/events.py +++ b/tradingchassis_core/core/events/events.py @@ -1,14 +1,5 @@ -"""Non-canonical telemetry and compatibility event records. +"""Non-canonical telemetry records.""" -This module is intentionally separate from canonical Event Stream candidates. -Records defined here are used for observability and compatibility projections: - -- telemetry / observability records (e.g. risk summaries, derived metrics) -- compatibility projection artifacts (e.g. inferred fill deltas) - -These records are transport payloads for local sinks and must not be interpreted -as canonical Event Stream semantics by default. -""" from __future__ import annotations from dataclasses import dataclass @@ -16,10 +7,8 @@ @dataclass(slots=True) class OrderStateTransitionEvent: - """Observability payload for invalid/edge order-state transitions. + """Observability payload for unexpected order-state transitions.""" - Telemetry only; not a canonical Event Stream record. - """ ts_ns_local: int instrument: str client_order_id: str @@ -27,62 +16,31 @@ class OrderStateTransitionEvent: next_state: str -@dataclass(slots=True) -class DerivedFillEvent: - """Inferred compatibility projection artifact. - - This record is derived from snapshot progression and is not a canonical - ``FillEvent`` or canonical Event Stream record. - """ - ts_ns_local: int - instrument: str - client_order_id: str - - side: str - - delta_qty: float - cum_qty: float - - price: float | None - - @dataclass(slots=True) class DerivedPnLEvent: - """Observability payload for derived realized-PnL changes. + """Observability payload for derived realized-PnL changes.""" - Telemetry only; not a canonical Event Stream record. - """ ts_ns_local: int instrument: str - delta_pnl: float cum_realized_pnl: float @dataclass(slots=True) class ExposureDerivedEvent: - """Observability payload for derived exposure changes. + """Observability payload for derived exposure changes.""" - Telemetry only; not a canonical Event Stream record. - """ ts_ns_local: int instrument: str - exposure: float delta_exposure: float @dataclass(slots=True) class RiskDecisionEvent: - """Observability payload summarizing risk/gate outcomes. + """Observability payload summarizing policy-risk outcomes.""" - Telemetry only; not a canonical Event Stream record. - """ ts_ns_local: int - accepted: int - queued: int rejected: int - handled: int - reject_reasons: dict[str, int] diff --git a/tradingchassis_core/core/execution_control/__init__.py b/tradingchassis_core/core/execution_control/__init__.py index 1a3be4a..7f123df 100644 --- a/tradingchassis_core/core/execution_control/__init__.py +++ b/tradingchassis_core/core/execution_control/__init__.py @@ -1,8 +1,12 @@ """Execution control (internal). -This package intentionally hosts internal components that govern queue admission, -inflight gating, and timing/rate limiting, while keeping RiskEngine focused on -policy decisions. +This package hosts internal components that govern Queue admission, inflight +gating, and rate limiting. Policy admission stays in the Risk Engine / domain +layer. + +``ControlSchedulingObligation`` (in ``types``) is a non-canonical scheduling hint +for **rate-limit** deferral only in the current Core slice; **inflight** deferral +does not emit that obligation by default. See ``docs/flows/control-time-and-scheduling.md``. """ from tradingchassis_core.core.execution_control.execution_control import ExecutionControl diff --git a/tradingchassis_core/core/execution_control/execution_control.py b/tradingchassis_core/core/execution_control/execution_control.py index 66f47e4..b7de265 100644 --- a/tradingchassis_core/core/execution_control/execution_control.py +++ b/tradingchassis_core/core/execution_control/execution_control.py @@ -1,10 +1,12 @@ """Execution control (internal extraction from RiskEngine). Owns: -- token bucket rate limiting state & math -- inflight gating that routes NEW/REPLACE to queue -- queue admission via StrategyState.merge_intents_into_queue(...) -- queue-only local handling for certain CANCEL/REPLACE cases +- token bucket rate limiting state & math (time-dependent deferral may surface + a ``ControlSchedulingObligation`` from the apply stage; see docs on control time) +- inflight gating that routes NEW/REPLACE to Queue (feedback-dependent; no + scheduling obligation by default) +- Queue admission via StrategyState.merge_intents_into_queue(...) +- Queue-only local handling for certain CANCEL/REPLACE cases """ from __future__ import annotations @@ -15,7 +17,7 @@ from typing import TYPE_CHECKING, Callable from tradingchassis_core.core.domain.reject_reasons import RejectReason -from tradingchassis_core.core.domain.types import NewOrderIntent, OrderIntent +from tradingchassis_core.core.domain.types import NewOrderIntent, OrderIntent, ReplaceOrderIntent from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation if TYPE_CHECKING: @@ -101,8 +103,10 @@ def route_after_policy_rate_limit( accept_now=False, stage_to_queue=True, scheduling_obligation=ControlSchedulingObligation( - ts_ns_local=wake_ts, + due_ts_ns_local=wake_ts, reason="rate_limit", + scope_key=f"instrument:{it.instrument}", + source="execution_control_rate_limit", ), ) return _RateRoutingResult( @@ -120,8 +124,10 @@ def route_after_policy_rate_limit( accept_now=False, stage_to_queue=True, scheduling_obligation=ControlSchedulingObligation( - ts_ns_local=wake_ts, + due_ts_ns_local=wake_ts, reason="rate_limit", + scope_key=f"instrument:{it.instrument}", + source="execution_control_rate_limit", ), ) @@ -165,6 +171,8 @@ def route_pre_submission_lifecycle_and_inflight( has_queued = state.has_queued_intent(it.instrument, it.client_order_id) if it.intent_type == "replace": + if not isinstance(it, ReplaceOrderIntent): + return False, RejectReason.INVALID_QTY if has_working: working = state.get_working_order_snapshot(it.instrument, it.client_order_id) if working is not None: @@ -206,6 +214,8 @@ def route_pre_submission_lifecycle_and_inflight( return False, RejectReason.ORDER_NOT_FOUND if it.intent_type == "replace": + if not isinstance(it, ReplaceOrderIntent): + return False, RejectReason.INVALID_QTY if not has_working: queued_new = state.find_queued_new_intent(it.instrument, it.client_order_id) if queued_new is None: @@ -251,7 +261,7 @@ def handle_cancel_against_queued_only_state( def handle_replace_against_queued_new( self, - it: OrderIntent, + it: ReplaceOrderIntent, *, state: StrategyState, queued_new: NewOrderIntent, @@ -260,12 +270,13 @@ def handle_replace_against_queued_new( queued: list[OrderIntent], handled_in_queue: list[OrderIntent], ) -> None: - """REPLACE acting on queued NEW: transform into updated NEW in the queue.""" + """REPLACE acting on queued NEW: transform into updated NEW in the Queue.""" removed = state.pop_queued_intents_for_order(it.instrument, it.client_order_id) for qi in removed: replaced_in_queue.append((qi.intent, it)) updated_new = NewOrderIntent( + intent_type="new", ts_ns_local=it.ts_ns_local, instrument=it.instrument, client_order_id=it.client_order_id, @@ -290,7 +301,7 @@ def handle_replace_against_queued_new( @staticmethod def is_replace_noop_against_working( *, - replace_intent: OrderIntent, + replace_intent: ReplaceOrderIntent, working_intended_price: float, working_intended_qty: float, float_equal: Callable[[float, float], bool], @@ -302,7 +313,7 @@ def is_replace_noop_against_working( @staticmethod def is_replace_noop_against_queued_new( *, - replace_intent: OrderIntent, + replace_intent: ReplaceOrderIntent, queued_new: NewOrderIntent, float_equal: Callable[[float, float], bool], ) -> bool: diff --git a/tradingchassis_core/core/execution_control/types.py b/tradingchassis_core/core/execution_control/types.py index 7d15a32..ae44282 100644 --- a/tradingchassis_core/core/execution_control/types.py +++ b/tradingchassis_core/core/execution_control/types.py @@ -16,6 +16,25 @@ class ControlSchedulingObligation: This is a derived control signal (not an Event) and does not mutate State. """ - ts_ns_local: int + due_ts_ns_local: int reason: str + scope_key: str + source: str + obligation_key: str = "" + + def __post_init__(self) -> None: + if self.obligation_key: + return + object.__setattr__( + self, + "obligation_key", + ( + f"{self.source}|{self.scope_key}|{self.reason}|{self.due_ts_ns_local}" + ), + ) + + @property + def ts_ns_local(self) -> int: + """Compatibility alias for pre-16F callers/tests.""" + return self.due_ts_ns_local diff --git a/tradingchassis_core/core/ports/__init__.py b/tradingchassis_core/core/ports/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tradingchassis_core/core/ports/engine_context.py b/tradingchassis_core/core/ports/engine_context.py deleted file mode 100644 index 57d0b83..0000000 --- a/tradingchassis_core/core/ports/engine_context.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import annotations - -from typing import Protocol - - -class EngineContext(Protocol): - """Read-only execution context exposed to strategies. - - This context intentionally hides engine / runtime specifics. - """ - - @property - def tick_size(self) -> float: - """Minimum price increment.""" diff --git a/tradingchassis_core/core/ports/venue_adapter.py b/tradingchassis_core/core/ports/venue_adapter.py deleted file mode 100644 index 2bdf756..0000000 --- a/tradingchassis_core/core/ports/venue_adapter.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Venue adapter protocol for strategy execution. - -This module defines the abstract venue-facing boundary used by the strategy -loop. Concrete implementations adapt specific backtest or live venues to -this protocol. -""" - -from __future__ import annotations - -from typing import Any, Protocol - - -class VenueAdapter(Protocol): - """Venue-facing feed boundary. - - The strategy loop must not depend on venue-specific APIs. - - Snapshot objects are intentionally typed as Any: they are only consumed by - venue-specific translation code, not by strategy/risk/state layers. - """ - - def wait_next(self, *, timeout_ns: int, include_order_resp: bool) -> int: - """Block until the next wakeup, returning a venue-defined rc code.""" - - def current_timestamp_ns(self) -> int: - """Return the venue local/receipt timestamp axis in ns.""" - - def read_market_snapshot(self) -> Any: - """Return the current market snapshot object (venue-specific).""" - - def read_orders_snapshot(self) -> tuple[Any, Any]: - """Return a tuple (state_values, orders) (venue-specific).""" - - def record(self, recorder: Any) -> None: - """Record the current venue state into the recorder (if supported).""" diff --git a/tradingchassis_core/core/ports/venue_policy.py b/tradingchassis_core/core/risk/execution_constraints_policy.py similarity index 61% rename from tradingchassis_core/core/ports/venue_policy.py rename to tradingchassis_core/core/risk/execution_constraints_policy.py index 1640b98..5a08003 100644 --- a/tradingchassis_core/core/ports/venue_policy.py +++ b/tradingchassis_core/core/risk/execution_constraints_policy.py @@ -1,9 +1,7 @@ -"""Venue policy normalization and validation logic. +"""Execution-constraint normalization and validation logic. -This module applies minimal, venue-agnostic constraints to order intents, -such as tick/lot rounding, post-only enforcement, and minimum notional checks. -The logic is intentionally explicit and branch-heavy to preserve correctness -and debuggability. +This module applies instrument/execution constraints to order intents, such as +tick/lot rounding, post-only enforcement, and minimum notional checks. """ from __future__ import annotations @@ -13,7 +11,13 @@ from typing import TYPE_CHECKING from tradingchassis_core.core.domain.reject_reasons import RejectReason -from tradingchassis_core.core.domain.types import NewOrderIntent, OrderIntent +from tradingchassis_core.core.domain.types import ( + NewOrderIntent, + OrderIntent, + Price, + Quantity, + ReplaceOrderIntent, +) if TYPE_CHECKING: from tradingchassis_core.core.domain.state import StrategyState @@ -21,27 +25,15 @@ @dataclass(slots=True) class NormalizationOutcome: - """Result of venue policy normalization.""" + """Result of execution-constraint normalization.""" normalized: OrderIntent | None reject_reason: str | None dropped: bool -class VenuePolicy: - """Minimal venue policy layer. - - Scope (kept intentionally small): - - tick rounding for limit prices - - lot rounding for intended quantity - - Venue-specific constraints can be enabled in a minimal form: - - post-only crossing checks (best-effort using top-of-book from state) - - min-notional checks - - The policy remains best-effort and intentionally avoids venue-specific - edge cases (self-trade prevention, reduce-only, advanced price sliding). - """ +class ExecutionConstraintsPolicy: + """Minimal instrument/execution constraints layer.""" def __init__( self, @@ -50,30 +42,25 @@ def __init__( post_only_mode: str = "reject", ) -> None: self._min_order_notional = float(min_order_notional) - mode = str(post_only_mode) if mode not in {"reject", "drop"}: raise ValueError(f"Invalid post_only_mode: {mode}") self._post_only_mode = mode - # pylint: disable=too-many-return-statements def normalize_intent(self, intent: OrderIntent, state: StrategyState) -> NormalizationOutcome: - """Normalize an intent according to venue constraints. - - Returns: - NormalizationOutcome with one of: - - normalized != None: normalized intent - - reject_reason != None: hard reject - - dropped == True: no-op intent (e.g. qty rounds to 0) - """ - if intent.intent_type == "cancel": return NormalizationOutcome(normalized=intent, reject_reason=None, dropped=False) + if not isinstance(intent, (NewOrderIntent, ReplaceOrderIntent)): + return NormalizationOutcome( + normalized=None, + reject_reason=RejectReason.INVALID_QTY, + dropped=False, + ) + tick_size = state.get_tick_size(intent.instrument) lot_size = state.get_lot_size(intent.instrument) - - qty = 0.0 if intent.intended_qty is None else float(intent.intended_qty.value) + qty = float(intent.intended_qty.value) qty_norm = self._round_qty(qty, lot_size) if qty_norm <= 0.0: return NormalizationOutcome(normalized=None, reject_reason=None, dropped=True) @@ -86,19 +73,19 @@ def normalize_intent(self, intent: OrderIntent, state: StrategyState) -> Normali reject_reason=RejectReason.INVALID_LIMIT_PRICE, dropped=False, ) - - px = float(intent.intended_price.value) - px_norm = self._round_price(px, tick_size, side=intent.side) + px_norm = self._round_price( + float(intent.intended_price.value), tick_size, side=intent.side + ) if px_norm is None or px_norm <= 0.0: return NormalizationOutcome( normalized=None, reject_reason=RejectReason.INVALID_LIMIT_PRICE, dropped=False, ) - - post_only_outcome = self._enforce_post_only(intent, state, px_norm) - if post_only_outcome is not None: - return post_only_outcome + if isinstance(intent, NewOrderIntent): + post_only_outcome = self._enforce_post_only(intent, state, px_norm) + if post_only_outcome is not None: + return post_only_outcome min_notional_outcome = self._enforce_min_notional(intent, state, qty_norm, px_norm) if min_notional_outcome is not None: @@ -110,47 +97,32 @@ def normalize_intent(self, intent: OrderIntent, state: StrategyState) -> Normali reject_reason=None, dropped=False, ) - - # replace return NormalizationOutcome( normalized=self._clone_replace(intent, qty_norm, px_norm), reject_reason=None, dropped=False, ) - # pylint: disable=too-many-return-statements def _enforce_post_only( self, - intent: OrderIntent, + intent: NewOrderIntent, state: StrategyState, px_norm: float, ) -> NormalizationOutcome | None: - if intent.intent_type != "new": - return None if intent.time_in_force != "POST_ONLY": return None - market = state.market[intent.instrument] if intent.instrument in state.market else None if market is None: return None - best_bid = float(market.best_bid) best_ask = float(market.best_ask) if best_bid <= 0.0 or best_ask <= 0.0: return None - - would_cross = False - if intent.side == "buy": - would_cross = px_norm >= best_ask - else: - would_cross = px_norm <= best_bid - + would_cross = px_norm >= best_ask if intent.side == "buy" else px_norm <= best_bid if not would_cross: return None - if self._post_only_mode == "drop": return NormalizationOutcome(normalized=None, reject_reason=None, dropped=True) - return NormalizationOutcome( normalized=None, reject_reason=RejectReason.POST_ONLY_WOULD_TRADE, @@ -159,30 +131,25 @@ def _enforce_post_only( def _enforce_min_notional( self, - intent: OrderIntent, + intent: NewOrderIntent | ReplaceOrderIntent, state: StrategyState, qty_norm: float, px_norm: float | None, ) -> NormalizationOutcome | None: if self._min_order_notional <= 0.0: return None - price = px_norm if intent.order_type == "market": mid = float(state.get_mid(intent.instrument)) if mid <= 0.0: return None price = mid - if price is None or price <= 0.0: return None - contract_size = float(state.get_contract_size(intent.instrument)) notional = float(price) * float(qty_norm) * contract_size - if notional + 1e-12 >= self._min_order_notional: return None - return NormalizationOutcome( normalized=None, reject_reason=RejectReason.MIN_NOTIONAL, @@ -203,44 +170,39 @@ def _round_price(price: float, tick_size: float, *, side: str) -> float | None: return None if tick_size <= 0.0: return float(price) - ticks = price / tick_size - if side == "buy": - rounded = math.floor(ticks) * tick_size - else: - rounded = math.ceil(ticks) * tick_size + rounded = math.floor(ticks) * tick_size if side == "buy" else math.ceil(ticks) * tick_size return float(rounded) @staticmethod - def _clone_new(intent: OrderIntent, qty: float, px: float | None) -> NewOrderIntent: - qty_unit = "contracts" if intent.intended_qty is None else intent.intended_qty.unit - price_ccy = "UNKNOWN" if intent.intended_price is None else intent.intended_price.currency - + def _clone_new(intent: NewOrderIntent, qty: float, px: float | None) -> NewOrderIntent: + price = intent.intended_price.value if px is None else px return NewOrderIntent( + intent_type="new", ts_ns_local=intent.ts_ns_local, instrument=intent.instrument, client_order_id=intent.client_order_id, intents_correlation_id=intent.intents_correlation_id, side=intent.side, order_type=intent.order_type, - intended_qty={"unit": qty_unit, "value": qty}, - intended_price=None - if px is None - else {"currency": price_ccy, "value": px}, + intended_qty=Quantity(unit=intent.intended_qty.unit, value=qty), + intended_price=Price(currency=intent.intended_price.currency, value=price), time_in_force=intent.time_in_force, ) @staticmethod - def _clone_replace(intent: OrderIntent, qty: float, px: float | None) -> OrderIntent: - # ReplaceOrderIntent shares the same field names as OrderIntent for the used fields. - payload = intent.model_dump() - qty_unit = "contracts" if intent.intended_qty is None else intent.intended_qty.unit - payload["intended_qty"] = {"unit": qty_unit, "value": qty} - - price_ccy = "UNKNOWN" if intent.intended_price is None else intent.intended_price.currency - payload["intended_price"] = ( - None - if px is None - else {"currency": price_ccy, "value": px} + def _clone_replace( + intent: ReplaceOrderIntent, qty: float, px: float | None + ) -> ReplaceOrderIntent: + price = intent.intended_price.value if px is None else px + return ReplaceOrderIntent( + intent_type="replace", + ts_ns_local=intent.ts_ns_local, + instrument=intent.instrument, + client_order_id=intent.client_order_id, + intents_correlation_id=intent.intents_correlation_id, + side=intent.side, + order_type=intent.order_type, + intended_qty=Quantity(unit=intent.intended_qty.unit, value=qty), + intended_price=Price(currency=intent.intended_price.currency, value=price), ) - return type(intent).model_validate(payload) diff --git a/tradingchassis_core/core/risk/risk_config.py b/tradingchassis_core/core/risk/risk_config.py index 7d8ac54..4b2c3f3 100644 --- a/tradingchassis_core/core/risk/risk_config.py +++ b/tradingchassis_core/core/risk/risk_config.py @@ -1,4 +1,4 @@ -"""Risk configuration model for backtest and live trading engines.""" +"""Typed deterministic risk configuration model.""" from __future__ import annotations @@ -16,56 +16,21 @@ class RiskConfig(BaseModel): - """Structured-only risk configuration.""" + """Structured risk configuration used by Core policy evaluation.""" scope: str = Field(..., min_length=1) trading_enabled: bool = True - - # Mirrors types.py RiskConstraints fields (types.py is the source of truth) position_limits: PositionLimits | None = None notional_limits: NotionalLimits | None = None quote_limits: QuoteLimits | None = None order_rate_limits: OrderRateLimits | None = None max_loss: MaxLoss | None = None - - # Optional additional config fields (kept separate from RiskConstraints.extra) extra: dict[str, Any] = Field(default_factory=dict) model_config = ConfigDict(extra="forbid") - @classmethod - def from_json_obj(cls, risk_obj: dict[str, Any]) -> RiskConfig: - """Create a RiskConfig instance from a JSON-compatible object.""" - return cls.model_validate(risk_obj) - @model_validator(mode="after") def validate_consistency(self) -> RiskConfig: - """Validate internal consistency of the risk configuration.""" if self.notional_limits is None: raise ValueError("notional_limits is required") return self - - @property - def params(self) -> dict[str, Any]: - """Return engine-compatible flat risk parameters.""" - return self.to_engine_params() - - def to_engine_params(self) -> dict[str, Any]: - """Convert the structured configuration into flat engine parameters.""" - params: dict[str, Any] = {} - - if self.position_limits is not None: - params["position_limits"] = self.position_limits - if self.notional_limits is not None: - params["notional_limits"] = self.notional_limits - if self.quote_limits is not None: - params["quote_limits"] = self.quote_limits - if self.order_rate_limits is not None: - params["order_rate_limits"] = self.order_rate_limits - if self.max_loss is not None: - params["max_loss"] = self.max_loss - - if self.extra: - params.update(self.extra) - - return params diff --git a/tradingchassis_core/core/risk/risk_engine.py b/tradingchassis_core/core/risk/risk_engine.py index 20d0d17..7d7f7da 100644 --- a/tradingchassis_core/core/risk/risk_engine.py +++ b/tradingchassis_core/core/risk/risk_engine.py @@ -1,166 +1,97 @@ -"""Risk engine implementing hard risk checks and intent gating.""" +"""Policy-risk evaluator used by deterministic Core policy admission.""" from __future__ import annotations -from collections import defaultdict -from dataclasses import dataclass from typing import TYPE_CHECKING from tradingchassis_core.core.domain.reject_reasons import RejectReason from tradingchassis_core.core.domain.types import OrderIntent, RiskConstraints -from tradingchassis_core.core.events.events import RiskDecisionEvent -from tradingchassis_core.core.execution_control import ExecutionControl -from tradingchassis_core.core.ports.venue_policy import VenuePolicy +from tradingchassis_core.core.risk.execution_constraints_policy import ( + ExecutionConstraintsPolicy, +) from tradingchassis_core.core.risk.risk_policy import RiskPolicy if TYPE_CHECKING: - from risk.risk_config import RiskConfig - from tradingchassis_core.core.domain.state import StrategyState - from tradingchassis_core.core.events.event_bus import EventBus - - -# --------------------------------------------------------------------------- -# Gate decision models (internal, not part of JSON schema) -# --------------------------------------------------------------------------- - - -@dataclass(slots=True) -class RejectedIntent: - intent: OrderIntent - reason: str - - -@dataclass(slots=True) -class GateDecision: - """Result of the hard risk/gate layer. - - Compatibility decision contract consumed by strategy/runtime orchestration. - This is not an Event and not a canonical Event Stream record. - - - accepted_now: intents that may be sent immediately - - queued: intents that were enqueued into StrategyState.queue (data-only) - - rejected: hard rejects with reasons - - replaced_in_queue: (old, new) pairs when queue replacement happened - - dropped_in_queue: intents that were dropped during queue merge (e.g. superseded) - - handled_in_queue: intents that were fully handled locally in the queue layer - (e.g. cancel/replace acting only on queued state) and must not be sent - - next_send_ts_ns_local: earliest local timestamp where it makes sense to wake up - to try flushing the queue (best-effort) - """ - - ts_ns_local: int - accepted_now: list[OrderIntent] - queued: list[OrderIntent] - rejected: list[RejectedIntent] - replaced_in_queue: list[tuple[OrderIntent, OrderIntent]] - dropped_in_queue: list[OrderIntent] - handled_in_queue: list[OrderIntent] - # Populated by the runner after outbound execution. - execution_rejected: list[RejectedIntent] - next_send_ts_ns_local: int | None + from tradingchassis_core.core.risk.risk_config import RiskConfig class RiskEngine: - """Hard risk and intent gating engine.""" - - # pylint: disable=too-many-instance-attributes - """Hard risk + gate layer. + """Policy-only evaluator. - This layer is allowed to: - - hard reject invalid / risk-breaching intents - - queue intents that should be sent later (rate limits, budgets) - - accept intents for immediate sending - - It must NOT submit orders itself. + This component is intentionally side-effect-free for the CoreStep policy phase: + it does not mutate Queue/rate/inflight state and does not perform Execution Control. """ - def __init__(self, risk_cfg: RiskConfig, event_bus: EventBus) -> None: + def __init__(self, risk_cfg: RiskConfig) -> None: self.risk_cfg = risk_cfg - self._event_bus = event_bus - venue_policy_cfg = self._parse_venue_policy_config(risk_cfg) - self._venue_policy = VenuePolicy( - min_order_notional=venue_policy_cfg["min_order_notional"], - post_only_mode=venue_policy_cfg["post_only_mode"], + min_order_notional, post_only_mode = self._parse_execution_constraints_config(risk_cfg) + self._constraints_policy = ExecutionConstraintsPolicy( + min_order_notional=min_order_notional, + post_only_mode=post_only_mode, ) - - self._risk_policy = RiskPolicy(venue_policy=self._venue_policy) - - # Internal execution-control component owns rate state and queue admission logic. - # RiskEngine must own a single instance to preserve state lifetime semantics. - self._execution_control = ExecutionControl() + self._risk_policy = RiskPolicy(constraints_policy=self._constraints_policy) @staticmethod - def _parse_venue_policy_config(risk_cfg: RiskConfig) -> dict[str, object]: - cfg: dict[str, object] = { - "min_order_notional": 0.0, - "post_only_mode": "reject", - } + def _parse_execution_constraints_config(risk_cfg: RiskConfig) -> tuple[float, str]: + min_order_notional = 0.0 + post_only_mode = "reject" extra = risk_cfg.extra if not isinstance(extra, dict): - return cfg + return min_order_notional, post_only_mode - # Preferred form: extra["venue_policy"] is a nested dict. - # RiskConstraints.extra requires flat scalar values, therefore - # nested config must be normalized before being exposed as constraints. - vp = extra["venue_policy"] if "venue_policy" in extra else None - if isinstance(vp, dict): - if "min_order_notional" in vp: + constraints = ( + extra["execution_constraints"] + if "execution_constraints" in extra + else None + ) + if isinstance(constraints, dict): + if "min_order_notional" in constraints: try: - cfg["min_order_notional"] = float(vp["min_order_notional"]) + min_order_notional = float(constraints["min_order_notional"]) except (TypeError, ValueError): pass - - if "post_only_mode" in vp: - mode = str(vp["post_only_mode"]) + if "post_only_mode" in constraints: + mode = str(constraints["post_only_mode"]) if mode in {"reject", "drop"}: - cfg["post_only_mode"] = mode - - return cfg + post_only_mode = mode + return min_order_notional, post_only_mode - # Backwards/alternative form: flattened keys. - if "venue_policy_min_order_notional" in extra: + if "execution_constraints_min_order_notional" in extra: try: - cfg["min_order_notional"] = float( - extra["venue_policy_min_order_notional"] - ) + min_order_notional = float( + extra["execution_constraints_min_order_notional"] + ) except (TypeError, ValueError): pass - if "venue_policy_post_only_mode" in extra: - mode = str(extra["venue_policy_post_only_mode"]) + if "execution_constraints_post_only_mode" in extra: + mode = str(extra["execution_constraints_post_only_mode"]) if mode in {"reject", "drop"}: - cfg["post_only_mode"] = mode + post_only_mode = mode - return cfg + return min_order_notional, post_only_mode @staticmethod - def _constraints_extra(extra: object) -> dict[str, object]: - """Normalize RiskConfig.extra for RiskConstraints. - - RiskConstraints.extra is defined as a flat mapping of scalar values - (str/float/bool/None). Nested dicts are not allowed. - - The normalization keeps the original mapping intact on RiskConfig, - but produces a flattened mapping for strategy constraints. - """ - + def _constraints_extra(extra: object) -> dict[str, str | float | bool | None]: if not isinstance(extra, dict): return {} - normalized: dict[str, object] = {} + normalized: dict[str, str | float | bool | None] = {} for key, value in extra.items(): - if key == "venue_policy" and isinstance(value, dict): - # Flatten nested venue policy config. + if key == "execution_constraints" and isinstance(value, dict): if "min_order_notional" in value: - normalized["venue_policy_min_order_notional"] = value["min_order_notional"] + normalized["execution_constraints_min_order_notional"] = value[ + "min_order_notional" + ] if "post_only_mode" in value: - normalized["venue_policy_post_only_mode"] = value["post_only_mode"] + normalized["execution_constraints_post_only_mode"] = value[ + "post_only_mode" + ] continue - # Only keep scalar values to match the RiskConstraints schema. if value is None or isinstance(value, (str, float, bool)): normalized[key] = value elif isinstance(value, int): @@ -168,17 +99,8 @@ def _constraints_extra(extra: object) -> dict[str, object]: return normalized - @staticmethod - def _float_equal(a: float, b: float) -> bool: - """Best-effort float equality for normalized values.""" - return abs(a - b) <= 1e-12 - - # --------------------------------------------------------------------- - # Soft constraints for strategy - # --------------------------------------------------------------------- - def build_constraints(self, current_timestamp_ns_local: int) -> RiskConstraints: - """Build RiskConstraints handed to the strategy.""" + """Build RiskConstraints handed to Strategy evaluation.""" extra = self._constraints_extra(self.risk_cfg.extra) return RiskConstraints( ts_ns_local=current_timestamp_ns_local, @@ -192,81 +114,28 @@ def build_constraints(self, current_timestamp_ns_local: int) -> RiskConstraints: extra=extra, ) - # --------------------------------------------------------------------- - # Hard gate decision - # --------------------------------------------------------------------- - - # pylint: disable=too-many-locals,too-many-branches,too-many-statements - def decide_intents( + def evaluate_policy_intent( self, - raw_intents: list[OrderIntent], + *, + intent: OrderIntent, state: StrategyState, now_ts_ns_local: int, - ) -> GateDecision: - """Hard gate decision. - - - Hard rejects: never send (risk breach / invalid) - - Queue: send later (rate limits / local budgets) - - Accept: send now - - NOTE: This implementation *does* enqueue queued intents into StrategyState - (data-only queue) by calling state.merge_intents_into_queue(). - """ + ) -> tuple[bool, str | None]: + """Evaluate one intent with policy-only checks and no side effects.""" - accepted_now: list[OrderIntent] = [] - to_queue_by_instr: defaultdict[str, list[OrderIntent]] = defaultdict(list) - rejected: list[RejectedIntent] = [] - replaced_in_queue: list[tuple[OrderIntent, OrderIntent]] = [] - dropped_in_queue: list[OrderIntent] = [] - handled_in_queue: list[OrderIntent] = [] + raw_intents = [intent] - # Intents that ended up queued due to rate limits or due to queue-only handling. - queued: list[OrderIntent] = [] - next_send_ts: int | None = None - - # counters for RiskDecisionEvent - reject_counts: dict[str, int] = {} - def _count_reject(reason: str) -> None: - reject_counts[reason] = reject_counts.get(reason, 0) + 1 - - # --- Trading enabled gate --- triggered, policy_accepted, policy_rejected = self._risk_policy.trading_enabled_gate( trading_enabled=self.risk_cfg.trading_enabled, raw_intents=raw_intents, ) if triggered: - accepted_now.extend(policy_accepted) - for it, reason in policy_rejected: - rejected.append(RejectedIntent(it, reason)) - _count_reject(reason) - - decision = GateDecision( - ts_ns_local=now_ts_ns_local, - accepted_now=accepted_now, - queued=[], - rejected=rejected, - replaced_in_queue=[], - dropped_in_queue=[], - handled_in_queue=[], - execution_rejected=[], - next_send_ts_ns_local=None, - ) + if policy_accepted: + return True, None + if policy_rejected: + return False, policy_rejected[0][1] + return False, RejectReason.TRADING_DISABLED - # emit summary - self._event_bus.emit( - RiskDecisionEvent( - ts_ns_local=now_ts_ns_local, - accepted=len(accepted_now), - queued=0, - rejected=len(rejected), - handled=len(handled_in_queue), - reject_reasons=reject_counts, - ) - ) - - return decision - - # --- Max loss (portfolio drawdown kill-switch) --- triggered, policy_accepted, policy_rejected = self._risk_policy.max_loss_gate( max_loss_cfg=self.risk_cfg.max_loss, raw_intents=raw_intents, @@ -274,170 +143,53 @@ def _count_reject(reason: str) -> None: now_ts_ns_local=now_ts_ns_local, ) if triggered: - accepted_now.extend(policy_accepted) - for it, reason in policy_rejected: - rejected.append(RejectedIntent(it, reason)) - _count_reject(reason) - - decision = GateDecision( - ts_ns_local=now_ts_ns_local, - accepted_now=accepted_now, - queued=[], - rejected=rejected, - replaced_in_queue=[], - dropped_in_queue=[], - handled_in_queue=[], - execution_rejected=[], - next_send_ts_ns_local=None, - ) - - self._event_bus.emit( - RiskDecisionEvent( - ts_ns_local=now_ts_ns_local, - accepted=len(accepted_now), - queued=0, - rejected=len(rejected), - handled=len(handled_in_queue), - reject_reasons=reject_counts, - ) - ) - - return decision - - # --- Rate limits (per second, local time) --- - rate_cfg = self.risk_cfg.order_rate_limits - max_orders_per_sec = None if rate_cfg is None else rate_cfg.max_orders_per_second - max_cancels_per_sec = None if rate_cfg is None else rate_cfg.max_cancels_per_second + if policy_accepted: + return True, None + if policy_rejected: + return False, policy_rejected[0][1] + return False, RejectReason.MAX_LOSS_DRAWDOWN + + norm = self._risk_policy.normalize_intent(intent, state) + if norm.reject_reason is not None: + return False, norm.reject_reason + if norm.dropped: + return False, "dropped_by_policy" + if norm.normalized is None: + return False, RejectReason.INVALID_QTY + + normalized_intent = norm.normalized + + ok, reason = self._risk_policy.validate_intent(normalized_intent, state) + if not ok: + return False, reason - # --- Position / notional limits --- pos_cfg = self.risk_cfg.position_limits max_pos = None if (pos_cfg is None or pos_cfg.max_position is None) else pos_cfg.max_position notional_cfg = self.risk_cfg.notional_limits - max_gross_notional = notional_cfg.max_gross_notional - max_single_order_notional = notional_cfg.max_single_order_notional + max_gross_notional = ( + None if notional_cfg is None else notional_cfg.max_gross_notional + ) + max_single_order_notional = ( + None if notional_cfg is None else notional_cfg.max_single_order_notional + ) quote_cfg = self.risk_cfg.quote_limits - quote_book = None if quote_cfg is not None: quote_book = self._risk_policy.quote_book_global(state) - - # Base portfolio gross notional (best-effort) base_gross_notional = self._risk_policy.portfolio_gross_notional(state) - # ----------------------------------------------------------------- - # Per-intent decision - # ----------------------------------------------------------------- - for it in raw_intents: - norm = self._risk_policy.normalize_intent(it, state) - if norm.reject_reason is not None: - rejected.append(RejectedIntent(it, norm.reject_reason)) - _count_reject(norm.reject_reason) - continue - if norm.dropped: - handled_in_queue.append(it) - continue - if norm.normalized is None: - rejected.append(RejectedIntent(it, RejectReason.INVALID_QTY)) - _count_reject(RejectReason.INVALID_QTY) - continue - - it = norm.normalized - # 0) Pre-submission lifecycle / identity / inflight routing compatibility handling. - continue_to_policy, lifecycle_reject_reason = ( - self._execution_control.route_pre_submission_lifecycle_and_inflight( - it, - state=state, - to_queue_by_instr=to_queue_by_instr, - replaced_in_queue=replaced_in_queue, - dropped_in_queue=dropped_in_queue, - queued=queued, - handled_in_queue=handled_in_queue, - float_equal=self._float_equal, - ) - ) - if not continue_to_policy: - if lifecycle_reject_reason is not None: - rejected.append(RejectedIntent(it, lifecycle_reject_reason)) - _count_reject(lifecycle_reject_reason) - continue - - # 1) Outbound hygiene validation (hard reject) - ok, reason = self._risk_policy.validate_intent(it, state) - if not ok: - rejected.append(RejectedIntent(it, reason)) - _count_reject(reason) - continue - - # 2) Hard risk checks (hard reject) - ok, reason = self._risk_policy.hard_checks( - it, - state, - max_pos=max_pos, - max_single_order_notional=max_single_order_notional, - max_gross_notional=max_gross_notional, - base_gross_notional=base_gross_notional, - quote_cfg=quote_cfg, - quote_book=quote_book, - ) - if not ok: - rejected.append(RejectedIntent(it, reason)) - _count_reject(reason) - continue - - # 3) Rate limiting -> queue (soft, not reject) - rate_result = self._execution_control.route_after_policy_rate_limit( - it, - now_ts_ns_local=now_ts_ns_local, - max_orders_per_sec=max_orders_per_sec, - max_cancels_per_sec=max_cancels_per_sec, - ) - if rate_result.stage_to_queue: - to_queue_by_instr[it.instrument].append(it) - obligation = rate_result.scheduling_obligation - if obligation is not None: - next_send_ts = ( - obligation.ts_ns_local - if next_send_ts is None - else min(next_send_ts, obligation.ts_ns_local) - ) - continue - - accepted_now.append(it) - - # ----------------------------------------------------------------- - # Queue merge per instrument (replacement rules live in StrategyState) - # ----------------------------------------------------------------- - self._execution_control.merge_to_queue_per_instrument( - state=state, - to_queue_by_instr=to_queue_by_instr, - queued=queued, - replaced_in_queue=replaced_in_queue, - dropped_in_queue=dropped_in_queue, - ) - - decision = GateDecision( - ts_ns_local=now_ts_ns_local, - accepted_now=accepted_now, - queued=queued, - rejected=rejected, - replaced_in_queue=replaced_in_queue, - dropped_in_queue=dropped_in_queue, - handled_in_queue=handled_in_queue, - execution_rejected=[], - next_send_ts_ns_local=next_send_ts, - ) - - self._event_bus.emit( - RiskDecisionEvent( - ts_ns_local=now_ts_ns_local, - accepted=len(accepted_now), - queued=len(queued), - rejected=len(rejected), - handled=len(handled_in_queue), - reject_reasons=reject_counts, - ) + ok, reason = self._risk_policy.hard_checks( + normalized_intent, + state, + max_pos=max_pos, + max_single_order_notional=max_single_order_notional, + max_gross_notional=max_gross_notional, + base_gross_notional=base_gross_notional, + quote_cfg=quote_cfg, + quote_book=quote_book, ) - - return decision + if not ok: + return False, reason + return True, None diff --git a/tradingchassis_core/core/risk/risk_policy.py b/tradingchassis_core/core/risk/risk_policy.py index 4e17f31..0356d6e 100644 --- a/tradingchassis_core/core/risk/risk_policy.py +++ b/tradingchassis_core/core/risk/risk_policy.py @@ -2,7 +2,7 @@ This module is intentionally internal and behavior-preserving: - It contains only policy checks (validation, kill-switches, hard limits). -- It does not perform queue admission, rate limiting, or inflight gating. +- It does not perform Queue admission, rate limiting, or inflight gating. """ from __future__ import annotations @@ -10,8 +10,11 @@ from typing import TYPE_CHECKING from tradingchassis_core.core.domain.reject_reasons import RejectReason -from tradingchassis_core.core.domain.types import OrderIntent -from tradingchassis_core.core.ports.venue_policy import NormalizationOutcome, VenuePolicy +from tradingchassis_core.core.domain.types import NewOrderIntent, OrderIntent, ReplaceOrderIntent +from tradingchassis_core.core.risk.execution_constraints_policy import ( + ExecutionConstraintsPolicy, + NormalizationOutcome, +) if TYPE_CHECKING: from tradingchassis_core.core.domain.state import StrategyState @@ -21,8 +24,8 @@ class RiskPolicy: """Pure policy layer used by RiskEngine.""" - def __init__(self, *, venue_policy: VenuePolicy) -> None: - self._venue_policy = venue_policy + def __init__(self, *, constraints_policy: ExecutionConstraintsPolicy) -> None: + self._constraints_policy = constraints_policy def trading_enabled_gate( self, @@ -66,10 +69,11 @@ def max_loss_gate( pnl = state.get_total_pnl() if pnl <= max_loss_cfg.max_drawdown: - return True, self._accept_cancels_reject_others( + accepted_now, rejected = self._accept_cancels_reject_others( raw_intents, RejectReason.MAX_LOSS_DRAWDOWN, ) + return True, accepted_now, rejected # Rolling loss kill-switch (equity change over a fixed window) if max_loss_cfg.rolling_loss is not None and max_loss_cfg.rolling_loss_window is not None: @@ -79,10 +83,11 @@ def max_loss_gate( window_ns=window_ns, ) if rolling is not None and rolling <= max_loss_cfg.rolling_loss: - return True, self._accept_cancels_reject_others( + accepted_now, rejected = self._accept_cancels_reject_others( raw_intents, RejectReason.MAX_LOSS_ROLLING, ) + return True, accepted_now, rejected return False, [], [] @@ -101,7 +106,7 @@ def _accept_cancels_reject_others( return accepted_now, rejected def normalize_intent(self, it: OrderIntent, state: StrategyState) -> NormalizationOutcome: - return self._venue_policy.normalize_intent(it, state) + return self._constraints_policy.normalize_intent(it, state) def validate_intent(self, it: OrderIntent, state: StrategyState) -> tuple[bool, str]: """Outbound intent sanity. @@ -142,7 +147,7 @@ def hard_checks( max_gross_notional: float | None, base_gross_notional: float | None, quote_cfg: QuoteLimits | None, - quote_book: dict[tuple[str, str | None, tuple[float, float]]], + quote_book: dict[tuple[str, str], tuple[float, float]] | None, ) -> tuple[bool, str]: """Apply hard risk checks. Returns (ok, reason).""" @@ -207,6 +212,8 @@ def hard_checks( return True, "OK" def intent_price(self, it: OrderIntent, state: StrategyState) -> float | None: + if not isinstance(it, (NewOrderIntent, ReplaceOrderIntent)): + return None if it.order_type == "limit": return None if it.intended_price is None else it.intended_price.value mid = state.get_mid(it.instrument) diff --git a/tradingchassis_core/core/schemas/common.schema.json b/tradingchassis_core/core/schemas/common.schema.json deleted file mode 100644 index ca8539e..0000000 --- a/tradingchassis_core/core/schemas/common.schema.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://schemas.example.com/common.schema.json", - "definitions": { - "Money": { - "type": "object", - "description": "Monetary amount with currency.", - "required": ["currency", "amount"], - "properties": { - "currency": { - "type": "string", - "minLength": 1, - "description": "Currency code (e.g. USD, EUR)." - }, - "amount": { - "type": "number", - "description": "Positive or negative monetary amount." - } - }, - "additionalProperties": false - }, - - "Price": { - "type": "object", - "description": "Price or rate with currency (e.g. price per unit).", - "required": ["currency", "value"], - "properties": { - "currency": { - "type": "string", - "minLength": 1, - "description": "Currency code (e.g. USD, EUR)." - }, - "value": { - "type": "number", - "minimum": 0, - "description": "Price value in quote currency. Price can be 0 as a placeholder." - } - }, - "additionalProperties": false - }, - - "Quantity": { - "type": "object", - "required": ["value", "unit"], - "properties": { - "value": { "type": "number", "minimum": 0, "description": "Value can be 0 for remaining quantity." }, - "unit": { "type": "string", "minLength": 1 } - }, - "additionalProperties": false - } - } -} diff --git a/tradingchassis_core/core/schemas/fill_event.schema.json b/tradingchassis_core/core/schemas/fill_event.schema.json deleted file mode 100644 index dd444cf..0000000 --- a/tradingchassis_core/core/schemas/fill_event.schema.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://example.com/schemas/fill_event.schema.json", - "title": "FillEvent", - "type": "object", - "required": [ - "ts_ns_exch", - "ts_ns_local", - "instrument", - "client_order_id", - "side", - "filled_price", - "cum_filled_qty", - "time_in_force", - "liquidity_flag" - ], - "properties": { - "ts_ns_exch": { - "description": "Venue fill event timestamp in nanoseconds since Unix epoch.", - "type": "integer", - "exclusiveMinimum": 0 - }, - "ts_ns_local": { - "description": "Local fill event timestamp in nanoseconds since Unix epoch.", - "type": "integer", - "exclusiveMinimum": 0 - }, - "instrument": { - "description": "Instrument identifier.", - "type": "string", - "minLength": 1 - }, - "client_order_id": { - "description": "Client-assigned order ID (if used).", - "type": "string", - "minLength": 1 - }, - "side": { - "description": "Side of the filled order from client perspective.", - "type": "string", - "enum": ["buy", "sell"] - }, - "intended_price": { - "description": "Intended price for the order.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Price" - }, - "filled_price": { - "description": "Filled price for the order.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Price" - }, - "intended_qty": { - "description": "Intended quantity of this order in underlying units.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" - }, - "cum_filled_qty": { - "description": "Cumulative filled quantity of this order in underlying units at this state.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" - }, - "remaining_qty": { - "description": "Remaining open quantity after this state.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" - }, - "time_in_force": { - "description": "Time-in-force instruction (venue-specific mapping).", - "type": "string", - "enum": ["GTC", "IOC", "FOK", "POST_ONLY"] - }, - "liquidity_flag": { - "description": "Indicates whether the fill was maker or taker.", - "type": "string", - "enum": ["maker", "taker", "unknown"] - }, - "fee": { - "description": "Transaction fee or rebate. Negative = fee paid, positive = rebate.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Money" - } - }, - "additionalProperties": false -} diff --git a/tradingchassis_core/core/schemas/market_event.schema.json b/tradingchassis_core/core/schemas/market_event.schema.json deleted file mode 100644 index 6b74db0..0000000 --- a/tradingchassis_core/core/schemas/market_event.schema.json +++ /dev/null @@ -1,121 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://example.com/schemas/market_event.schema.json", - "title": "MarketEvent", - "type": "object", - "required": [ - "ts_ns_exch", - "ts_ns_local", - "instrument", - "event_type" - ], - "properties": { - "ts_ns_exch": { - "description": "Venue event timestamp in nanoseconds since Unix epoch.", - "type": "integer", - "exclusiveMinimum": 0 - }, - "ts_ns_local": { - "description": "Local event timestamp in nanoseconds since Unix epoch.", - "type": "integer", - "exclusiveMinimum": 0 - }, - "instrument": { - "description": "Instrument identifier, venue-specific.", - "type": "string", - "minLength": 1 - }, - "event_type": { - "description": "Type of market event.", - "type": "string", - "enum": ["book", "trade"] - }, - "book": { - "description": "Payload for order book events.", - "type": "object", - "required": ["book_type", "bids", "asks"], - "properties": { - "book_type": { - "description": "Whether this is a full snapshot or an incremental update.", - "type": "string", - "enum": ["snapshot", "delta"] - }, - "bids": { - "description": "Bid levels, highest price first.", - "type": "array", - "items": { - "type": "object", - "required": ["price", "quantity"], - "properties": { - "price": { "$ref": "https://schemas.example.com/common.schema.json#/definitions/Price" }, - "quantity": { "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" } - }, - "additionalProperties": false - } - }, - "asks": { - "description": "Ask levels, lowest price first.", - "type": "array", - "items": { - "type": "object", - "required": ["price", "quantity"], - "properties": { - "price": { "$ref": "https://schemas.example.com/common.schema.json#/definitions/Price" }, - "quantity": { "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" } - }, - "additionalProperties": false - } - }, - "depth": { - "description": "Depth of the book represented by this event (number of levels).", - "type": "integer", - "minimum": 0 - } - }, - "additionalProperties": false - }, - "trade": { - "description": "Payload for trade events.", - "type": "object", - "required": ["side", "price", "quantity"], - "properties": { - "side": { - "description": "Taker side of the trade.", - "type": "string", - "enum": ["buy", "sell"] - }, - "price": { - "description": "Trade price.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Price" - }, - "quantity": { - "description": "Trade quantity in contract units.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" - }, - "trade_id": { - "description": "Venue-provided trade identifier, if available.", - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - } - }, - "allOf": [ - { - "if": { "properties": { "event_type": { "const": "book" } } }, - "then": { - "required": ["book"], - "not": { "required": ["trade"] } - } - }, - { - "if": { "properties": { "event_type": { "const": "trade" } } }, - "then": { - "required": ["trade"], - "not": { "required": ["book"] } - } - } - ], - "additionalProperties": false -} diff --git a/tradingchassis_core/core/schemas/order_intent.schema.json b/tradingchassis_core/core/schemas/order_intent.schema.json deleted file mode 100644 index 9b4522e..0000000 --- a/tradingchassis_core/core/schemas/order_intent.schema.json +++ /dev/null @@ -1,187 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://example.com/schemas/order_intent.schema.json", - "title": "OrderIntent", - "type": "object", - "description": "Schema for an order intent used as a single source of truth across strategy, validation, and (backtest) execution bindings. The schema models intent-specific requirements via oneOf (new/cancel/replace).", - "required": [ - "ts_ns_local", - "instrument", - "client_order_id", - "intent_type" - ], - "properties": { - "ts_ns_local": { - "type": "integer", - "description": "Local intent timestamp in nanoseconds since Unix epoch.", - "exclusiveMinimum": 0 - }, - "instrument": { - "type": "string", - "description": "Instrument identifier used for routing and execution binding (e.g., symbol, asset code). Required for all intents, including cancel.", - "minLength": 1 - }, - "client_order_id": { - "type": "string", - "description": "Order identifier (maps to hftbacktest 'order_id'). Used for new/cancel/replace. Must be unique while an order with the same ID exists.", - "minLength": 1 - }, - "intent_type": { - "type": "string", - "enum": [ - "new", - "cancel", - "replace" - ], - "description": "Intent type describing the order lifecycle action." - }, - "intents_correlation_id": { - "type": "string", - "description": "Optional correlation identifier to link multiple intents (e.g., decision bundles) across the order lifecycle.", - "minLength": 1 - }, - "order_type": { - "type": "string", - "enum": [ - "limit", - "market" - ], - "description": "Order type. For replace intents this must be 'limit'." - }, - "side": { - "type": "string", - "enum": [ - "buy", - "sell" - ], - "description": "Order side." - }, - "intended_price": { - "description": "Intended order price. Required for both limit and market orders to match the execution binding signature.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Price" - }, - "intended_qty": { - "description": "Intended total order quantity. For replace intents, this is the new total quantity (not a delta).", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" - }, - "time_in_force": { - "type": "string", - "enum": [ - "GTC", - "IOC", - "FOK", - "POST_ONLY" - ], - "description": "Time in force. Required for new intents. Not allowed for replace intents because the binding does not support modifying it." - } - }, - "oneOf": [ - { - "title": "NewOrderIntent", - "description": "Create a new order.", - "type": "object", - "properties": { - "intent_type": { - "const": "new" - } - }, - "required": [ - "ts_ns_local", - "instrument", - "client_order_id", - "intent_type", - "side", - "order_type", - "intended_qty", - "intended_price", - "time_in_force" - ] - }, - { - "title": "CancelOrderIntent", - "description": "Cancel an existing order identified by client_order_id.", - "type": "object", - "properties": { - "intent_type": { - "const": "cancel" - } - }, - "required": [ - "ts_ns_local", - "instrument", - "client_order_id", - "intent_type" - ], - "allOf": [ - { - "not": { - "required": [ - "side" - ] - } - }, - { - "not": { - "required": [ - "order_type" - ] - } - }, - { - "not": { - "required": [ - "intended_qty" - ] - } - }, - { - "not": { - "required": [ - "intended_price" - ] - } - }, - { - "not": { - "required": [ - "time_in_force" - ] - } - } - ] - }, - { - "title": "ReplaceOrderIntent", - "description": "Modify an existing order (limit-only). The order ID remains the same (client_order_id). Time-in-force cannot be modified.", - "type": "object", - "properties": { - "intent_type": { - "const": "replace" - }, - "order_type": { - "const": "limit" - } - }, - "required": [ - "ts_ns_local", - "instrument", - "client_order_id", - "intent_type", - "side", - "order_type", - "intended_qty", - "intended_price" - ], - "allOf": [ - { - "not": { - "required": [ - "time_in_force" - ] - } - } - ] - } - ], - "additionalProperties": false -} diff --git a/tradingchassis_core/core/schemas/order_state_event.schema.json b/tradingchassis_core/core/schemas/order_state_event.schema.json deleted file mode 100644 index 41149e1..0000000 --- a/tradingchassis_core/core/schemas/order_state_event.schema.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://example.com/schemas/order_state_event.schema.json", - "title": "OrderStateEvent", - "type": "object", - "required": [ - "ts_ns_exch", - "ts_ns_local", - "instrument", - "client_order_id", - "order_type", - "state_type", - "side", - "intended_price", - "intended_qty", - "time_in_force" - ], - "properties": { - "ts_ns_exch": { - "description": "Venue state event timestamp in nanoseconds since Unix epoch.", - "type": "integer", - "exclusiveMinimum": 0 - }, - "ts_ns_local": { - "description": "Local state event timestamp in nanoseconds since Unix epoch.", - "type": "integer", - "exclusiveMinimum": 0 - }, - "instrument": { - "description": "Instrument identifier.", - "type": "string", - "minLength": 1 - }, - "client_order_id": { - "description": "Client-assigned order ID (if used).", - "type": "string", - "minLength": 1 - }, - "order_type": { - "description": "Order type indicating how the order was intended to be executed.", - "type": "string", - "enum": ["limit", "market"] - }, - "state_type": { - "description": "Lifecycle state of the order.", - "type": "string", - "enum": [ - "pending_new", - "accepted", - "working", - "partially_filled", - "filled", - "canceled", - "expired", - "rejected", - "replaced" - ] - }, - "side": { - "description": "Side of the filled order from client perspective.", - "type": "string", - "enum": ["buy", "sell"] - }, - "intended_price": { - "description": "Intended price for the order.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Price" - }, - "filled_price": { - "description": "Filled price for the order.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Price" - }, - "intended_qty": { - "description": "Intended quantity of this order in underlying units.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" - }, - "cum_filled_qty": { - "description": "Cumulative filled quantity of this order in underlying units at this state.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" - }, - "remaining_qty": { - "description": "Remaining open quantity after this state.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" - }, - "time_in_force": { - "description": "Time-in-force instruction (venue-specific mapping).", - "type": "string", - "enum": ["GTC", "IOC", "FOK", "POST_ONLY"] - }, - "reason": { - "description": "Optional reason or error code for this state change.", - "type": "string", - "minLength": 1 - }, - "raw": { - "description": "Optional raw payload from venue or OMS for debugging.", - "type": "object" - } - }, - "additionalProperties": false -} diff --git a/tradingchassis_core/core/schemas/risk_constraints.schema.json b/tradingchassis_core/core/schemas/risk_constraints.schema.json deleted file mode 100644 index 8336204..0000000 --- a/tradingchassis_core/core/schemas/risk_constraints.schema.json +++ /dev/null @@ -1,166 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://example.com/schemas/risk_constraints.schema.json", - "title": "RiskConstraints", - "type": "object", - "required": [ - "ts_ns_local", - "scope", - "trading_enabled" - ], - "properties": { - "ts_ns_local": { - "description": "Local risk state timestamp in nanoseconds since Unix epoch.", - "type": "integer", - "exclusiveMinimum": 0 - }, - "scope": { - "description": "Scope for which these constraints apply (e.g. strategy name, account).", - "type": "string", - "minLength": 1 - }, - "trading_enabled": { - "description": "Global on/off flag. If false, no new risk-increasing orders should be sent.", - "type": "boolean" - }, - - "position_limits": { - "description": "Limits expressed in underlying units (e.g. shares, contracts). Position value is interpreted in the given currency context.", - "type": "object", - "required": ["currency"], - "properties": { - "currency": { - "description": "Currency context for evaluating position limits (e.g. 'USD', 'EUR')", - "type": "string", - "minLength": 1 - }, - "max_position": { - "description": "Maximum allowed absolute position in underlying units (applies to the net position if the venue auto-nets).", - "type": "number", - "minimum": 0 - } - }, - "additionalProperties": false - }, - - "notional_limits": { - "description": "Limits expressed in monetary notional (exposure) in a given currency.", - "type": "object", - "required": [ - "currency" - ], - "properties": { - "currency": { - "description": "Currency identifier used for notional limits (e.g. 'USD', 'EUR', 'SimDollar'). No specific format enforced.", - "type": "string", - "minLength": 1 - }, - "max_gross_notional": { - "description": "Maximum allowed gross notional exposure (sum of absolute notionals across instruments) in the given currency.", - "type": "number", - "minimum": 0 - }, - "max_single_order_notional": { - "description": "Maximum allowed notional for a single new order in the given currency.", - "type": "number", - "minimum": 0 - } - }, - "additionalProperties": false - }, - - "quote_limits": { - "description": "Limits on outstanding quote exposure, expressed in a monetary currency.", - "type": "object", - "required": [ - "currency" - ], - "properties": { - "currency": { - "description": "Currency identifier used for quote-notional limits (e.g. 'USD', 'EUR', 'SimDollar'). No specific format enforced.", - "type": "string", - "minLength": 1 - }, - "max_gross_quote_notional": { - "description": "Maximum gross notional of all active quotes (sum of absolute notional of all bid/ask quotes) in the given currency.", - "type": "number", - "minimum": 0 - }, - "max_net_quote_notional": { - "description": "Maximum net notional of active quotes (bid notional - ask notional).", - "type": "number" - }, - "max_active_quotes": { - "description": "Maximum number of active quotes across all instruments.", - "type": "integer", - "minimum": 0 - } - }, - "additionalProperties": false - }, - - "order_rate_limits": { - "description": "Rate limits for order and cancel activity.", - "type": "object", - "properties": { - "max_orders_per_second": { - "description": "Maximum number of new orders per second.", - "type": "number", - "minimum": 0 - }, - "max_cancels_per_second": { - "description": "Maximum number of cancel requests per second.", - "type": "number", - "minimum": 0 - } - }, - "additionalProperties": false - }, - - "max_loss": { - "description": "Maximum allowed realized + unrealized losses.", - "type": "object", - "required": [ - "currency", - "max_drawdown" - ], - "properties": { - "currency": { - "description": "Currency identifier used for the daily loss limit (e.g. 'USD', 'EUR', 'SimDollar'). No specific format enforced.", - "type": "string", - "minLength": 1 - }, - "max_drawdown": { - "description": "Maximum permitted portfolio loss measured from the most recent equity peak (peak-to-trough drawdown limit).", - "type": "number", - "exclusiveMaximum": 0 - }, - "rolling_loss": { - "description": "Maximum permitted loss within a rolling time window; used as an additional regime/bug protection alongside the max_drawdown limit.", - "type": "number", - "exclusiveMaximum": 0 - }, - "rolling_loss_window": { - "description": "Size of rolling loss measurement window in minutes, defining how far back losses are accumulated for the rolling_loss check.", - "type": "number", - "exclusiveMinimum": 0 - } - }, - "additionalProperties": false - }, - - "extra": { - "description": "Extension point for additional risk parameters.", - "type": "object", - "additionalProperties": { - "type": [ - "string", - "number", - "boolean", - "null" - ] - } - } - }, - "additionalProperties": false -} diff --git a/tradingchassis_core/strategies/__init__.py b/tradingchassis_core/strategies/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tradingchassis_core/strategies/base.py b/tradingchassis_core/strategies/base.py deleted file mode 100644 index e6389e8..0000000 --- a/tradingchassis_core/strategies/base.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Base strategy interface. - -This module defines the Strategy protocol used by the backtest and -live execution engines. Concrete strategies implement this interface and are -driven exclusively by venue wakeups and risk engine decisions. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from tradingchassis_core.core.domain.state import StrategyState - from tradingchassis_core.core.domain.types import MarketEvent, OrderIntent, RiskConstraints - from tradingchassis_core.core.ports.engine_context import EngineContext - from tradingchassis_core.core.risk.risk_engine import GateDecision - - -class Strategy(ABC): - """Strategy protocol implemented by all concrete strategies. - - The strategy is triggered by two event sources: - - Feed events (rc=2): market data changes such as book/trade updates. - - Order updates (rc=3): order responses / fills / cancels reflected in state snapshots. - - The strategy must NOT assume that created intents are already live in the market. - Live state must be derived from StrategyState order snapshots. - """ - - @abstractmethod - def on_feed( - self, - state: StrategyState, - event: MarketEvent, - engine_cfg: EngineContext, - constraints: RiskConstraints, - ) -> list[OrderIntent]: - """Handle a feed wakeup (rc=2) and produce zero or more raw OrderIntents.""" - - @abstractmethod - def on_order_update( - self, - state: StrategyState, - engine_cfg: EngineContext, - constraints: RiskConstraints, - ) -> list[OrderIntent]: - """Handle an order update wakeup (rc=3) and produce zero or more raw OrderIntents. - - This hook is used for live-like behavior, e.g. reacting to fills, rejects, - or cancels without waiting for the next market data tick. - """ - - @abstractmethod - def on_risk_decision(self, decision: GateDecision) -> None: - """Receive GateDecision feedback (accepted, queued, rejected with reasons).""" diff --git a/tradingchassis_core/strategies/strategy_config.py b/tradingchassis_core/strategies/strategy_config.py deleted file mode 100644 index 3fe9e62..0000000 --- a/tradingchassis_core/strategies/strategy_config.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Strategy configuration model. - -This module defines the StrategyConfig schema used to parse and normalize -strategy-related configuration from JSON into engine-consumable parameters. -""" - -from __future__ import annotations - -from typing import Any - -from pydantic import BaseModel, ConfigDict, Field, model_validator - - -class StrategyConfig(BaseModel): - """Strategy config that collects arbitrary extra keys into ``params``. - - JSON example: - "strategy": { - "class_path": "my_strategies.debug:DebugStrategy", - "spread": 50.0, - "size": 0.0001 - } - - Result: - class_path="my_strategies.debug:DebugStrategy" - params={"spread": 50.0, "size": 0.0001} - """ - - class_path: str = Field(..., min_length=1) - - params: dict[str, Any] = Field(default_factory=dict) - - model_config = ConfigDict(extra="allow") - - @model_validator(mode="before") - @classmethod - def _collect_extras_into_params(cls, data: Any) -> Any: - """Collect unknown top-level keys into the ``params`` mapping. - - This allows flat JSON strategy configuration without requiring - a nested "params" object. - """ - if not isinstance(data, dict): - return data - - d = dict(data) - - explicit_params = d.pop("params", None) - - reserved = {"class_path"} - - extras = {k: v for k, v in d.items() if k not in reserved} - - for k in extras.keys(): - d.pop(k, None) - - merged: dict[str, Any] = {} - if isinstance(explicit_params, dict): - merged.update(explicit_params) - merged.update(extras) - - d["params"] = merged - return d - - def to_engine_params(self) -> dict[str, Any]: - """Return a shallow copy of strategy parameters. - - The engine must not mutate configuration state. - """ - return dict(self.params)