diff --git a/CHANGELOG.md b/CHANGELOG.md index dc57654..c5db4a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,21 +17,35 @@ This changelog starts from the clean Core package baseline. - Deterministic `run_core_step` and `run_core_wakeup_step` architecture. - CoreWakeupStep final Strategy evaluation: reduce all entries, then `CoreWakeupStrategyEvaluator` once. - Canonical Event input models and `EventStreamEntry`/`ProcessingPosition`. -- Intent Pipeline candidate records with dominance/reconciliation. +- Intent pipeline candidate records 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. - Root export of `PolicyIntentEvaluator` and documentation of extension points vs convenience implementations. - Pipeline integration tests for `RiskEngine` as `policy_evaluator` in `run_core_step`. -- `FillEvent` reducer and Pipeline tests. +- `FillEvent` reducer and pipeline tests. - Runnable Risk Engine example at `examples/core_step_with_risk_engine.py`. - Extension-point docs under `docs/` and U3 candidate list at `docs/roadmap/dead-code-cleanup-candidates.md`. +- Explicit terminal canonical Order lifecycle Events: + `OrderCanceledEvent`, `OrderRejectedEvent`, and `OrderExpiredEvent`. +- Terminal lifecycle reduction behavior and focused semantics tests for + deterministic working-order removal, canonical projection terminal state, and + inflight cleanup. ### Changed - Package metadata, exports, and docs reset for standalone Core library identity. - Pydantic models established as contract source of truth across public API docs. -- README clarifies internally wired Pipeline vs externally supplied extension points. +- README clarifies internally wired pipeline vs externally supplied extension points. +- Runtime/Core contract wording hardened with a normative Runtime-obligation + matrix, explicit `CoreStepResult` output semantics, and clearer + `ControlSchedulingObligation` vs `ControlTimeEvent` boundary language. +- Canonical `MarketEvent` contract wording now explicitly documents the current + book-only reduction baseline; trade-shaped payloads are explicitly unsupported + for canonical reduction in this slice. +- Public API docs/tests hardening for Core-F2: root Public API classification, + Advanced API labeling, root-vs-non-root clarity, and explicit surface lock + coverage for all current root exports. No exports were removed or renamed. ### Removed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a74f28..1f984aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ python -m build - Register canonical category handling in `core/domain/event_model.py`. - Update canonical reduction behavior in `core/domain/processing.py`. -### CoreStep/CoreWakeupStep Pipeline +### CoreStep/CoreWakeupStep pipeline - Update `core/domain/processing_step.py` for deterministic flow changes. - Keep reconciliation/policy/apply transitions explicit and side-effect-safe. diff --git a/README.md b/README.md index f0488ad..4a9e380 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,10 @@ stay in the Runtime, Venue Adapter, and Venue—not in Core. | What you get | Why it matters | | --- | --- | -| One deterministic Core Pipeline | Same Event-step path for reduction → evaluation → candidates → Risk Engine → Execution Control apply | +| 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) | +| 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 | Core is designed to reduce decision-logic drift between Backtesting @@ -103,7 +103,7 @@ flowchart TB 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, +pipeline and contracts; what varies by design is Runtime choice, Venue Adapter, Venue, and deployment. @@ -133,7 +133,7 @@ Run the quickstart python examples/core_step_quickstart.py ``` -Optional Risk Engine policy example (same Pipeline, built-in policy): +Optional Risk Engine policy example (same pipeline, built-in policy): ```bash python examples/core_step_with_risk_engine.py @@ -170,9 +170,9 @@ Planned U3 cleanup candidates: [`docs/roadmap/dead-code-cleanup-candidates.md`]( -## Full Pipeline +## Full pipeline -Internal processing Pipeline, in sequential order: +Internal processing pipeline, in sequential order: ```text Runtime reduces to canonical Events @@ -189,11 +189,42 @@ Runtime reduces to canonical Events Runtime dispatches Intents into Orders ``` +Runtime/Core contract matrix (Core perspective): see +[`docs/reference/events-reference.md`](docs/reference/events-reference.md) +for the normative Runtime/external outcome -> canonical Event injection table and +reducer effects. + +Terminal Order lifecycle baseline in this Core slice: + +- Core now accepts explicit canonical terminal lifecycle Events: + `OrderCanceledEvent`, `OrderRejectedEvent`, and `OrderExpiredEvent`. +- Runtime owns venue I/O and must inject these canonical Events when terminal + outcomes are confirmed/reported in the Event Stream. +- Terminal Event reduction removes active working-order projections, updates + canonical order projection state, and clears inflight tracking for + `instrument + client_order_id`. +- `OrderRejectedEvent` is an Order lifecycle execution outcome and is distinct + from Policy Admission rejection in the Intent pipeline. +- Dispatch failure before submission is not automatically `OrderRejectedEvent`. +- `OrderExecutionFeedbackEvent` is account-level feedback and does not replace + `FillEvent` or terminal lifecycle Events. +- This slice does not add `OrderAcceptedEvent` and does not introduce a full + order state machine framework. + +Current Market Event baseline contract: + +- Core canonical reduction supports book-shaped `MarketEvent` payloads for + Market State reduction. +- Trade-shaped `MarketEvent` payloads are not reduced by the current baseline + and are explicitly rejected at canonical processing boundaries. +- Runtime may normalize venue data into canonical Events, but Core accepts only + the documented canonical reduction contract. + ## Internally wired vs externally supplied -The clean Core Pipeline is always the same shape; some pieces run inside Core +The clean Core pipeline is always the same shape; some pieces run inside Core when you call step APIs, and others must be supplied by your Runtime or tests. ### Internally wired (always part of Core when you call step APIs) @@ -207,6 +238,7 @@ when you call step APIs, and others must be supplied by your Runtime or tests. ### Externally supplied extension points - **Strategy evaluator** — `CoreStepStrategyEvaluator` or `CoreWakeupStrategyEvaluator` + receives a read-only `StrategyStateView` via `context.state` and returns Intents - **Policy evaluator** — any object implementing `PolicyIntentEvaluator` (passed via `CorePolicyAdmissionContext`) - **Execution Control** — `ExecutionControl` instance (passed via `CoreExecutionControlApplyContext`) - **Configuration** — optional `CoreConfiguration` for positioned market reduction @@ -223,6 +255,9 @@ The minimal quickstart uses an inline allow-all policy to stay small. That does built-in Risk Engine policy behavior. See `examples/core_step_quickstart.py` (minimal) and `examples/core_step_with_risk_engine.py` (Risk Engine variant). +Strategy code reads State and returns Intents. It must not mutate Core-owned +State, Queue/inflight substate, or reducer-managed data. + ## Input / Core / Output / Not Owned By Core @@ -256,7 +291,7 @@ work is reconsidered after canonical execution Events update State. Runtimes mus 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). -In short: one Pipeline, canonical Events, Intents inside Core, policy vs Execution +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. diff --git a/SECURITY.md b/SECURITY.md index aa5e6af..5228104 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -25,7 +25,7 @@ Include: This policy covers the Core package in this repository, including: - canonical Event and Intent contracts -- deterministic CoreStep/CoreWakeupStep decision Pipeline +- deterministic CoreStep/CoreWakeupStep decision pipeline - package integrity and dependency usage in `tradingchassis_core` ## Secrets and Credentials Policy diff --git a/docs/code-map/core-pipeline-map.md b/docs/code-map/core-pipeline-map.md index ca5336e..eb53918 100644 --- a/docs/code-map/core-pipeline-map.md +++ b/docs/code-map/core-pipeline-map.md @@ -1,6 +1,6 @@ -# Core Pipeline Map +# Core pipeline Map -This map captures the only supported deterministic decision Pipeline for +This map captures the only supported deterministic decision pipeline for TradingChassis Core. ## Step-by-step flow @@ -17,7 +17,7 @@ TradingChassis Core. 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. + external dispatch or mutate Queues outside this pipeline. ## Core APIs @@ -46,7 +46,7 @@ Wakeup flow: 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 +4. `run_core_wakeup_decision` snapshots queued Intents once, combines generated + queued once, applies dominance/reconciliation once, Policy Admission once, and Execution Control plan/apply once. 5. `CoreStepResult.dispatchable_intents` is returned; Runtime dispatches later. diff --git a/docs/code-map/repository-map.md b/docs/code-map/repository-map.md index d3c3d34..e506469 100644 --- a/docs/code-map/repository-map.md +++ b/docs/code-map/repository-map.md @@ -6,7 +6,7 @@ High-level map for the standalone Core package. - `tradingchassis_core/__init__.py`: public package boundary exports - `tradingchassis_core/core/domain/`: canonical contracts and deterministic - Pipeline orchestration + pipeline orchestration - `tradingchassis_core/core/risk/`: policy-only Risk Engine evaluator/config - `tradingchassis_core/core/execution_control/`: Execution Control primitives - `tradingchassis_core/core/events/`: Event bus/sink utilities (`NullEventBus`; `LoggingEventSink` for Runtime) diff --git a/docs/flows/control-time-and-scheduling.md b/docs/flows/control-time-and-scheduling.md index 0d13594..3c3a7d9 100644 --- a/docs/flows/control-time-and-scheduling.md +++ b/docs/flows/control-time-and-scheduling.md @@ -11,12 +11,13 @@ to Execution Control deferral. canonical Event Stream and does not mutate `StrategyState`. - **Control-Time Event** (`ControlTimeEvent`) — Canonical **control** category Event. It becomes part of the deterministic history only after the **Runtime** injects it as - `EventStreamEntry` input (same ingestion path as other canonical Events). + `EventStreamEntry` input (same ingestion path as other canonical Events). It is + sparse/deadline-style control feedback, not a periodic tick requirement. - **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. + 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`). @@ -37,12 +38,17 @@ or “synthetic” obligations for inflight-only waits. `ControlTimeEvent`. Obligations are for **rate-limit** rechecks in the current Core slice. -## Clean Core Pipeline (unchanged) +`ControlSchedulingObligation` is produced in Execution Control **apply** for +time-dependent rate-limit deferral. It does not mutate Strategy State and does +not enter the Event Stream directly. Runtime may realize it later by injecting +a canonical `ControlTimeEvent`. + +## Clean Core pipeline (unchanged) 1. `EventStreamEntry` 2. `process_event_entry` / `process_canonical_event` 3. Strategy evaluator -4. generated intents +4. generated Intents 5. candidate records + dominance / reconciliation 6. policy admission 7. Execution Control plan / apply diff --git a/docs/how-to/update-core-step-pipeline.md b/docs/how-to/update-core-step-pipeline.md index e888ee0..0364229 100644 --- a/docs/how-to/update-core-step-pipeline.md +++ b/docs/how-to/update-core-step-pipeline.md @@ -1,4 +1,4 @@ -# How To Update CoreStep Pipeline Behavior +# How To Update CoreStep pipeline Behavior Core step orchestration lives in `tradingchassis_core/core/domain/processing_step.py`. @@ -19,7 +19,7 @@ Recommended workflow: Guardrails: -- No Runtime dispatch logic in Core Pipeline code. +- No Runtime dispatch logic in Core pipeline code. - No legacy compatibility contract restoration. - Keep deterministic behavior and public API coherence. diff --git a/docs/index.md b/docs/index.md index ea5cc1e..5de0a58 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,7 +7,7 @@ This documentation set describes the standalone clean Core package baseline. - `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/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 @@ -20,7 +20,7 @@ This documentation set describes the standalone clean Core package baseline. TradingChassis Core is a deterministic trading decision engine library. It owns canonical contracts, State reduction, and step-level decision outputs. -## Clean Core Pipeline +## Clean Core pipeline 1. `EventStreamEntry` 2. `process_event_entry` / `process_canonical_event` diff --git a/docs/reference/events-reference.md b/docs/reference/events-reference.md index f5a7e20..5df2ed0 100644 --- a/docs/reference/events-reference.md +++ b/docs/reference/events-reference.md @@ -5,20 +5,82 @@ contracts. Pydantic models are the schema source of truth. ## Canonical Event Models -- `MarketEvent`: book/trade market data input for State reduction +- `MarketEvent`: book market data input for Market State reduction in the current Core baseline - `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 +- `OrderCanceledEvent`: canonical terminal lifecycle feedback for a canceled Order +- `OrderRejectedEvent`: canonical terminal lifecycle feedback for a rejected Order +- `OrderExpiredEvent`: canonical terminal lifecycle feedback for an expired Order +- `OrderExecutionFeedbackEvent`: canonical account feedback (account/position/balance projection) - `FillEvent`: canonical fill lifecycle update +## Runtime obligation matrix (Core perspective) + +Core is deterministic reduction and decision logic. Runtime owns Venue I/O, +adapter dispatch, and canonical Event injection into the Event Stream. + +Core never performs external dispatch. Runtime dispatches +`CoreStepResult.dispatchable_intents` later and injects canonical feedback Events. + +| Runtime / external outcome | Canonical Event to inject into Core | Core reducer effect | Notes / non-goals | +| --- | --- | --- | --- | +| Book market data input | `MarketEvent` | Updates Market State projection and monotone local time | Current Core baseline is book-only; trade-shaped `MarketEvent` is rejected for canonical reduction. | +| Rate-limit recheck obligation becomes due | `ControlTimeEvent` | Canonical control-time reduction updates monotone local time | `ControlSchedulingObligation` is non-canonical output and does not enter the Event Stream directly. | +| Successful external NEW dispatch | `OrderSubmittedEvent` | Creates/updates active Order projection and clears inflight for `instrument + client_order_id` | Submission acknowledgement boundary for NEW dispatch outcomes. | +| Authoritative fill feedback | `FillEvent` | Updates fill history, cumulative fill quantity, and Order fill progression | Runtime fill ingress is environment-specific and can be deferred by Runtime capability tracks. | +| Account/execution snapshot feedback | `OrderExecutionFeedbackEvent` | Updates account projection only | Not a replacement for `FillEvent`; does not encode terminal Order Lifecycle status. | +| Cancel confirmation / terminal cancel outcome | `OrderCanceledEvent` | Removes active working Order, sets terminal canonical projection (`canceled`), clears inflight | Terminal Order Lifecycle feedback path. | +| Venue/order rejection after dispatch | `OrderRejectedEvent` | Removes active working Order, sets terminal canonical projection (`rejected`), clears inflight | Distinct from Policy Admission rejection (pre-dispatch). | +| Order expiry | `OrderExpiredEvent` | Removes active working Order, sets terminal canonical projection (`expired`), clears inflight | Terminal lifecycle closure from runtime feedback. | + +Explicit contract notes: + +- `CoreStepResult.generated_intents`, `candidate_intents`, + `candidate_intent_records`, and `core_step_decision` are introspection / + diagnostic outputs, not external dispatch obligations. +- Dispatch failure before submission is not automatically `OrderRejectedEvent`. + `OrderRejectedEvent` is a terminal Order Lifecycle feedback Event after + dispatch. +- Policy Admission rejection is not `OrderRejectedEvent`; Policy Admission + occurs before dispatch in the Order Intent pipeline. +- `mark_intent_sent` is not part of the public Runtime/Core contract boundary. +- Runtime loop progress/no-spin behavior, pending scheduling lifecycle, + hftbacktest behavior, and recorder behavior are runtime-repository concerns, + not Core contract behavior. + +Terminal lifecycle reducer contract in this Core baseline: + +- `OrderCanceledEvent`, `OrderRejectedEvent`, and `OrderExpiredEvent` update + `StrategyState` deterministically by: + - removing the Order from active working-order projections; + - updating canonical order projection state (`"canceled"`, `"rejected"`, or + `"expired"`); + - clearing inflight tracking for `instrument + client_order_id`. +- Terminal Event reduction is idempotent and non-crashing for unknown orders: + Core records terminal canonical projection state when no active working order + exists. +- Order rejection (`OrderRejectedEvent`) is an execution-side Order lifecycle + outcome and is distinct from Policy Admission rejection (which occurs before + dispatch in the Intent pipeline). + Canonical ingestion boundary: - `process_canonical_event(state, event, ...)` - `process_event_entry(state, EventStreamEntry(...), ...)` +### MarketEvent baseline contract + +In the current Core baseline, canonical reduction supports only book-shaped +`MarketEvent` payloads (`event_type="book"` with book levels). + +Trade-shaped `MarketEvent` payloads are reserved in the schema but are not part +of the supported canonical reduction contract in this baseline. If a trade-shaped +`MarketEvent` reaches canonical reduction, Core rejects it with explicit +validation error behavior. + ## Processing Order Models - `ProcessingPosition` diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index ccecdb6..5ba9edf 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -1,109 +1,179 @@ # Public API Reference -The public package boundary is the `tradingchassis_core` root import. +The Public API boundary is the `tradingchassis_core` root import. -## Internally wired vs externally supplied +This page classifies root exports into: -### Internally wired (when step APIs run) +- Public Data Model +- Public Extension Point +- Public Orchestration API +- Public Convenience Implementation +- Advanced API -These run inside Core when you call `run_core_step` / CoreWakeupStep APIs (no substitute -implementation required): +`ControlSchedulingObligation` is a non-canonical output. `ControlTimeEvent` is the +canonical Event symbol for the Control-Time Event concept. -- `process_event_entry` / `process_canonical_event` and canonical reducers -- Candidate combination, dominance, and reconciliation -- Policy Admission **mechanism** when `CorePolicyAdmissionContext` is provided -- Execution Control plan/apply **mechanism** when policy + apply contexts are provided -- `CoreStepResult` / `CoreStepDecision` production +## Public Orchestration API -### Externally supplied extension points +Primary orchestration entrypoints: -| Symbol | Role | -| --- | --- | -| `CoreStepStrategyEvaluator` / `CoreWakeupStrategyEvaluator` | Strategy evaluation | -| `PolicyIntentEvaluator` | Policy Admission (`evaluate_policy_intent`) via `CorePolicyAdmissionContext` | -| `ExecutionControl` | Queue/rate/inflight apply via `CoreExecutionControlApplyContext` | -| `CoreConfiguration` | Optional instrument metadata for positioned market reduction | -| `EventBus` / `NullEventBus` | `StrategyState` requires a bus; use `NullEventBus` for standalone Core | +- `run_core_step` +- `run_core_wakeup_step` +- `process_event_entry` +- `process_canonical_event` -### Convenience implementations (optional) +Advanced orchestration entrypoints (split CoreWakeupStep flow): -| Symbol | Role | -| --- | --- | -| Risk Engine (`RiskEngine`) | Built-in `PolicyIntentEvaluator` (not wired by default) | -| `ExecutionControl` | Default Execution Control apply implementation (you still supply an instance) | -| `NullEventBus` | Discards events for tests and examples | +- `run_core_wakeup_reduction` +- `run_core_wakeup_decision` -**Internal (not public extension points):** `RiskPolicy`, `ExecutionConstraintsPolicy`, -and other modules under `core/risk/` except `RiskEngine` / `RiskConfig`. +Core step orchestration is deterministic: Runtime provides canonical Event Stream +input, Core reduces canonical Events, evaluates Strategy, applies Policy Admission +and Execution Control, and returns `CoreStepResult`. -Examples: +Primary integrations should start with `run_core_step` or `run_core_wakeup_step`. +Split APIs are public Advanced API for diagnostics, testing, and Runtime/Core split +flows. -- Minimal inline policy: `examples/core_step_quickstart.py` -- Built-in Risk Engine policy: `examples/core_step_with_risk_engine.py` +## Public Data Model -## Canonical Events +Core and processing models: + +- `CoreConfiguration` +- `ProcessingPosition` +- `EventStreamEntry` +- `CoreStepResult` +- `StrategyStateView` + +Canonical Event models: - `MarketEvent` - `ControlTimeEvent` - `OrderSubmittedEvent` +- `OrderCanceledEvent` +- `OrderRejectedEvent` +- `OrderExpiredEvent` - `OrderExecutionFeedbackEvent` - `FillEvent` -## Step APIs +Order Intent and numeric models: -- `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) +- `OrderIntent` +- `NewOrderIntent` +- `CancelOrderIntent` +- `ReplaceOrderIntent` +- `Price` +- `Quantity` -## Step inputs/outputs +Risk data models: -- `EventStreamEntry` -- `ProcessingPosition` -- `CorePolicyAdmissionContext` (holds `PolicyIntentEvaluator`) -- `CoreExecutionControlApplyContext` (holds `ExecutionControl`) -- `CoreStepDecision` -- `CoreStepResult` -- `CoreWakeupReductionResult` -- `CoreWakeupStrategyContext` +- `RiskConfig` +- `RiskConstraints` +- `NotionalLimits` + +Current baseline notes: + +- canonical Event reduction for `MarketEvent` is book-only in the current Core + baseline. +- Trade-shaped `MarketEvent` payloads are not reduced in this baseline and are + explicitly rejected at canonical processing boundaries. +- Policy Admission rejection is not an Order Lifecycle terminal Event. + +## Public Extension Point + +- `CoreStepStrategyEvaluator` - `CoreWakeupStrategyEvaluator` +- `PolicyIntentEvaluator` +- `CorePolicyAdmissionContext` -## Policy and risk +Strategy evaluators read `StrategyStateView` through context and return Order Intent +values. Policy evaluators are supplied through `CorePolicyAdmissionContext`. -- `PolicyIntentEvaluator` (protocol) -- `PolicyRiskDecision` -- `PolicyAdmissionResult` -- `PolicyRejectedCandidate` -- Risk Engine (`RiskEngine`) (convenience `PolicyIntentEvaluator`) -- `RiskConfig` -- `RiskConstraints` (data model; often built for Strategy via `RiskEngine.build_constraints`) +## Public Convenience Implementation -## Supporting deterministic models +- `RiskEngine` +- `ExecutionControl` +- `NullEventBus` + +These remain public for convenience and compatibility. Runtime still supplies +instances and owns external dispatch and canonical Event injection. + +## Advanced API + +Advanced state, context, and split-step models: -- `CoreConfiguration` - `StrategyState` -- `CandidateIntentRecord` +- `CoreStepStrategyContext` +- `CoreWakeupStrategyContext` +- `CoreWakeupReductionResult` +- `CoreExecutionControlApplyContext` + +Advanced introspection and decision scaffolds: + - `CandidateIntentOrigin` +- `CandidateIntentRecord` +- `CoreStepDecision` - `ExecutionControlDecision` -- `ExecutionControl` -- `ControlSchedulingObligation` (non-canonical; **rate-limit** recheck hint in the - current slice—see `../flows/control-time-and-scheduling.md`) +- `PolicyRiskDecision` +- `PolicyRejectedCandidate` +- `PolicyAdmissionResult` -## Intents and numeric models +Advanced runtime-facing non-canonical output: -- `OrderIntent` -- `NewOrderIntent` -- `CancelOrderIntent` -- `ReplaceOrderIntent` -- `Price` -- `Quantity` -- `NotionalLimits` +- `ControlSchedulingObligation` -## Runtime-safe utilities +Advanced deterministic slot helpers: -- `NullEventBus` +- `SlotKey` +- `stable_slot_order_id` + +Advanced API symbols are public for compatibility and diagnostics, split-step +integration, and advanced testing. They are not themselves Runtime dispatch +obligations. Runtime dispatch obligations remain `CoreStepResult.dispatchable_intents` +plus canonical Event injection behavior. + +## State and Strategy boundary + +- `StrategyState` is an advanced mutable Core state container. +- Core reducers and Execution Control own mutation of Strategy State internals. +- Strategy code should read `StrategyStateView` via Strategy contexts. +- `StrategyStateView` is the public read-only Strategy boundary model. +- Queue/inflight internals and reducer methods are not Strategy mutation APIs. + +`CoreStepStrategyContext` is an advanced Strategy adapter context. `context.state` is +`StrategyStateView`, not mutable `StrategyState`. + +## Root export surface + +The current root export surface includes all symbols listed on this page and: + +- `__version__` + +## Root vs non-root API + +Some APIs are public but are module-level (non-root) rather than root exports. +Example: + +- `EventBus` from `tradingchassis_core.core.events.event_bus` + +`EventBus` is intentionally non-root. `NullEventBus` is the root convenience export. + +## Internally wired vs externally supplied + +Internally wired when step APIs run: + +- canonical Event reduction and candidate reconciliation +- Policy Admission mechanism when `CorePolicyAdmissionContext` is provided +- Execution Control plan/apply mechanism when execution apply context is provided +- `CoreStepResult` and advanced decision scaffolds production + +Externally supplied: + +- Strategy evaluator (`CoreStepStrategyEvaluator` or `CoreWakeupStrategyEvaluator`) +- Policy evaluator (`PolicyIntentEvaluator`) +- Execution Control instance (`ExecutionControl`) +- optional `CoreConfiguration` +- Event bus (`EventBus` module-level API, or root `NullEventBus`) ## Publicly absent by design @@ -114,7 +184,6 @@ Examples: - `OrderStateEvent` - `DerivedFillEvent` - `VenueAdapter` / `VenuePolicy` -- `RiskPolicy` / `ExecutionConstraintsPolicy` (internal to Risk Engine / `RiskEngine`) -- `fold_event_stream_entries` (removed U3; loop `process_event_entry` instead) +- `RiskPolicy` / `ExecutionConstraintsPolicy` (internal to `RiskEngine`) +- `fold_event_stream_entries` (removed U3; use `process_event_entry` loop) - `apply_execution_control_plan` and apply detail record types (internal; use `CoreStepResult`) -- Telemetry event types formerly in `core/events/events.py` (removed U3) diff --git a/docs/roadmap/dead-code-cleanup-candidates.md b/docs/roadmap/dead-code-cleanup-candidates.md index 945c14c..0ebe7d7 100644 --- a/docs/roadmap/dead-code-cleanup-candidates.md +++ b/docs/roadmap/dead-code-cleanup-candidates.md @@ -7,7 +7,7 @@ what was kept, and what remains deferred. | Item | Rationale | | --- | --- | -| `StrategyState.pop_queued_intents` | No callers in the Core Pipeline; Runtime tests (`core-runtime`) only monkeypatch the name to assert it is **not** invoked | +| `StrategyState.pop_queued_intents` | No callers in the Core pipeline; Runtime tests (`core-runtime`) only monkeypatch the name to assert it is **not** invoked | | `fold_event_stream_entries` | Zero callers; batch reduction is `process_event_entry` in a loop | | `core/events/events.py` telemetry models | Never emitted; only referenced by unused `TELEMETRY_EVENT_TYPES` | | `combine_candidate_intents` | Unused wrapper around `combine_candidate_intent_records` | @@ -20,7 +20,7 @@ what was kept, and what remains deferred. | `RiskEngine.build_constraints` | Called from Runtime (`core-runtime` `strategy_runner.py`) for Strategy evaluation | | `SlotKey`, `stable_slot_order_id` | Used by Runtime (`core-runtime` `debug_strategy.py`) | | `sink_logging.LoggingEventSink` | Used by Runtime (`core-runtime` `strategy_runner.py`) | -| Risk Engine (`RiskEngine`), `RiskPolicy`, `ExecutionConstraintsPolicy`, `PolicyIntentEvaluator`, canonical Events, Core step APIs | Active extension points / Pipeline | +| Risk Engine (`RiskEngine`), `RiskPolicy`, `ExecutionConstraintsPolicy`, `PolicyIntentEvaluator`, canonical Events, Core step APIs | Active extension points / pipeline | ## Deferred (intentionally not removed) diff --git a/examples/core_step_quickstart.py b/examples/core_step_quickstart.py index cd308d8..c7187de 100644 --- a/examples/core_step_quickstart.py +++ b/examples/core_step_quickstart.py @@ -119,7 +119,7 @@ def main() -> None: 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. + # 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) diff --git a/tests/semantics/test_core_pipeline_clean.py b/tests/semantics/test_core_pipeline_clean.py index 921a3dc..7daae42 100644 --- a/tests/semantics/test_core_pipeline_clean.py +++ b/tests/semantics/test_core_pipeline_clean.py @@ -1,4 +1,4 @@ -"""Clean CoreStep/CoreWakeupStep Pipeline tests.""" +"""Clean CoreStep/CoreWakeupStep pipeline tests.""" from __future__ import annotations diff --git a/tests/semantics/test_fill_event_reduction.py b/tests/semantics/test_fill_event_reduction.py index f2fb495..191c5c5 100644 --- a/tests/semantics/test_fill_event_reduction.py +++ b/tests/semantics/test_fill_event_reduction.py @@ -1,4 +1,4 @@ -"""FillEvent canonical reduction and Core step Pipeline coverage.""" +"""FillEvent canonical reduction and Core step pipeline coverage.""" from __future__ import annotations diff --git a/tests/semantics/test_market_event_contract.py b/tests/semantics/test_market_event_contract.py new file mode 100644 index 0000000..017715d --- /dev/null +++ b/tests/semantics/test_market_event_contract.py @@ -0,0 +1,115 @@ +"""MarketEvent canonical contract tests for book-only baseline behavior.""" + +from __future__ import annotations + +import pytest + +import tradingchassis_core as tc +from tradingchassis_core.core.domain.types import BookLevel, BookPayload, TradePayload + +INSTRUMENT = "BTC-USDC-PERP" +BOOK_ONLY_ERROR = ( + "only book MarketEvent payloads are reduced; trade-shaped MarketEvent payloads " + "are not supported" +) + + +def _book_event(*, ts_ns_local: int) -> tc.MarketEvent: + return tc.MarketEvent( + ts_ns_exch=ts_ns_local - 1, + ts_ns_local=ts_ns_local, + 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 _trade_event(*, ts_ns_local: int) -> tc.MarketEvent: + return tc.MarketEvent( + ts_ns_exch=ts_ns_local - 1, + ts_ns_local=ts_ns_local, + instrument=INSTRUMENT, + event_type="trade", + trade=TradePayload( + side="buy", + price=tc.Price(currency="USDC", value=100.0), + quantity=tc.Quantity(value=0.5, unit="contracts"), + trade_id="trade-1", + ), + ) + + +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_process_canonical_event_accepts_book_market_event() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + tc.process_canonical_event(state, _book_event(ts_ns_local=200)) + + market = state.market[INSTRUMENT] + assert market.best_bid == 99.0 + assert market.best_ask == 101.0 + assert market.mid == 100.0 + assert state.sim_ts_ns_local == 200 + + +def test_market_event_trade_shape_can_be_constructed_but_reduction_rejects() -> None: + trade_event = _trade_event(ts_ns_local=210) + assert trade_event.is_trade() + assert trade_event.trade is not None + + state = tc.StrategyState(event_bus=tc.NullEventBus()) + with pytest.raises(ValueError, match=BOOK_ONLY_ERROR): + tc.process_canonical_event(state, trade_event) + + +def test_run_core_step_rejects_trade_market_event() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + trade_entry = tc.EventStreamEntry( + position=tc.ProcessingPosition(index=0), + event=_trade_event(ts_ns_local=220), + ) + + with pytest.raises(ValueError, match=BOOK_ONLY_ERROR): + tc.run_core_step(state, trade_entry) + + +def test_run_core_wakeup_step_rejects_trade_market_event() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + entries = ( + _control_entry(0, 300), + tc.EventStreamEntry( + position=tc.ProcessingPosition(index=1), + event=_trade_event(ts_ns_local=301), + ), + ) + + with pytest.raises(ValueError, match=BOOK_ONLY_ERROR): + tc.run_core_wakeup_step(state, entries) diff --git a/tests/semantics/test_order_terminal_lifecycle.py b/tests/semantics/test_order_terminal_lifecycle.py new file mode 100644 index 0000000..bed3da9 --- /dev/null +++ b/tests/semantics/test_order_terminal_lifecycle.py @@ -0,0 +1,242 @@ +"""Terminal order lifecycle canonical Event reduction coverage.""" + +from __future__ import annotations + +import pytest + +import tradingchassis_core as tc + +INSTRUMENT = "BTC-USDC-PERP" +ORDER_ID = "terminal-order-1" + + +def _submitted_entry(index: int, ts: int) -> tc.EventStreamEntry: + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=tc.OrderSubmittedEvent( + ts_ns_local_dispatch=ts, + instrument=INSTRUMENT, + client_order_id=ORDER_ID, + side="buy", + order_type="limit", + intended_price=tc.Price(currency="USDC", value=100.0), + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + time_in_force="GTC", + intent_correlation_id=None, + dispatch_attempt_id=None, + runtime_correlation=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 _terminal_event(kind: str, ts: int) -> object: + if kind == "canceled": + return tc.OrderCanceledEvent( + ts_ns_local_feedback=ts, + instrument=INSTRUMENT, + client_order_id=ORDER_ID, + ) + if kind == "rejected": + return tc.OrderRejectedEvent( + ts_ns_local_feedback=ts, + instrument=INSTRUMENT, + client_order_id=ORDER_ID, + ) + if kind == "expired": + return tc.OrderExpiredEvent( + ts_ns_local_feedback=ts, + instrument=INSTRUMENT, + client_order_id=ORDER_ID, + ) + raise AssertionError(f"unsupported terminal kind: {kind}") + + +def _terminal_entry(index: int, kind: str, ts: int) -> tc.EventStreamEntry: + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=_terminal_event(kind, ts), + ) + + +@pytest.mark.parametrize("symbol", ("OrderCanceledEvent", "OrderRejectedEvent", "OrderExpiredEvent")) +def test_terminal_events_are_public_exports(symbol: str) -> None: + assert hasattr(tc, symbol) + + +@pytest.mark.parametrize("terminal_kind", ("canceled", "rejected", "expired")) +def test_submitted_then_terminal_event_removes_working_order_updates_projection_and_clears_inflight( + terminal_kind: str, +) -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + tc.process_event_entry(state, _submitted_entry(0, 100)) + state.mark_intent_sent(INSTRUMENT, ORDER_ID, "replace") + assert state.has_inflight(INSTRUMENT, ORDER_ID) + assert state.has_working_order(INSTRUMENT, ORDER_ID) + + tc.process_event_entry(state, _terminal_entry(1, terminal_kind, 101)) + + assert not state.has_working_order(INSTRUMENT, ORDER_ID) + assert not state.has_inflight(INSTRUMENT, ORDER_ID) + projection = state.canonical_orders[(INSTRUMENT, ORDER_ID)] + assert projection.state == terminal_kind + assert projection.updated_ts_ns_local == 101 + + +@pytest.mark.parametrize("terminal_kind", ("canceled", "rejected", "expired")) +def test_process_canonical_event_routes_terminal_events(terminal_kind: str) -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + tc.process_event_entry(state, _submitted_entry(0, 100)) + tc.process_canonical_event(state, _terminal_event(terminal_kind, 101)) + + assert not state.has_working_order(INSTRUMENT, ORDER_ID) + assert state.canonical_orders[(INSTRUMENT, ORDER_ID)].state == terminal_kind + + +@pytest.mark.parametrize("terminal_kind", ("canceled", "rejected", "expired")) +def test_run_core_step_reduces_terminal_event(terminal_kind: str) -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + tc.process_event_entry(state, _submitted_entry(0, 100)) + + result = tc.run_core_step( + state, + _terminal_entry(1, terminal_kind, 101), + ) + + assert result.generated_intents == () + assert result.dispatchable_intents == () + assert not state.has_working_order(INSTRUMENT, ORDER_ID) + assert state.canonical_orders[(INSTRUMENT, ORDER_ID)].state == terminal_kind + + +@pytest.mark.parametrize("terminal_kind", ("canceled", "rejected", "expired")) +def test_run_core_wakeup_step_reduces_terminal_events_in_processing_order( + terminal_kind: str, +) -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + + class _AssertTerminalReducedEvaluator: + def evaluate(self, context: tc.CoreWakeupStrategyContext) -> list[tc.OrderIntent]: + assert context.last_position is not None + assert context.last_position.index == 2 + assert not context.state.orders.get(INSTRUMENT, {}).get(ORDER_ID) + return [] + + result = tc.run_core_wakeup_step( + state, + ( + _submitted_entry(0, 100), + _control_entry(1, 101), + _terminal_entry(2, terminal_kind, 102), + ), + wakeup_strategy_evaluator=_AssertTerminalReducedEvaluator(), + ) + + assert result.generated_intents == () + assert not state.has_working_order(INSTRUMENT, ORDER_ID) + assert state.canonical_orders[(INSTRUMENT, ORDER_ID)].state == terminal_kind + + +@pytest.mark.parametrize("terminal_kind", ("canceled", "rejected", "expired")) +def test_repeated_terminal_event_is_idempotent(terminal_kind: str) -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + tc.process_event_entry(state, _submitted_entry(0, 100)) + first = _terminal_entry(1, terminal_kind, 101) + second = _terminal_entry(2, terminal_kind, 101) + + tc.process_event_entry(state, first) + projection_after_first = state.canonical_orders[(INSTRUMENT, ORDER_ID)] + state_snapshot = ( + projection_after_first.state, + projection_after_first.submitted_ts_ns_local, + projection_after_first.updated_ts_ns_local, + ) + + tc.process_event_entry(state, second) + projection_after_second = state.canonical_orders[(INSTRUMENT, ORDER_ID)] + assert not state.has_working_order(INSTRUMENT, ORDER_ID) + assert ( + projection_after_second.state, + projection_after_second.submitted_ts_ns_local, + projection_after_second.updated_ts_ns_local, + ) == state_snapshot + + +@pytest.mark.parametrize("terminal_kind", ("canceled", "rejected", "expired")) +def test_terminal_event_for_unknown_order_is_deterministic_and_non_crashing( + terminal_kind: str, +) -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + + tc.process_event_entry(state, _terminal_entry(0, terminal_kind, 100)) + + assert not state.has_working_order(INSTRUMENT, ORDER_ID) + projection = state.canonical_orders[(INSTRUMENT, ORDER_ID)] + assert projection.state == terminal_kind + assert projection.submitted_ts_ns_local == 100 + assert projection.updated_ts_ns_local == 100 + + +def test_order_rejected_event_is_distinct_from_policy_admission_rejection() -> 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 _OneIntentEvaluator: + def evaluate(self, context: object) -> list[tc.NewOrderIntent]: + _ = context + return [ + tc.NewOrderIntent( + intent_type="new", + ts_ns_local=100, + instrument=INSTRUMENT, + client_order_id="policy-reject-order", + intents_correlation_id="corr-policy-reject", + 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", + ) + ] + + 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.core_step_decision is not None + assert len(result.core_step_decision.policy_rejected_intents) == 1 + assert (INSTRUMENT, "policy-reject-order") not in state.canonical_orders diff --git a/tests/semantics/test_public_api_clean.py b/tests/semantics/test_public_api_clean.py index a204efa..44cd30e 100644 --- a/tests/semantics/test_public_api_clean.py +++ b/tests/semantics/test_public_api_clean.py @@ -2,52 +2,80 @@ from __future__ import annotations +import re +from pathlib import Path + import tradingchassis_core as tc +EXPECTED_ROOT_EXPORTS = ( + "CoreConfiguration", + "RiskConfig", + "RiskEngine", + "StrategyState", + "StrategyStateView", + "MarketEvent", + "ControlTimeEvent", + "OrderSubmittedEvent", + "OrderCanceledEvent", + "OrderRejectedEvent", + "OrderExpiredEvent", + "OrderExecutionFeedbackEvent", + "FillEvent", + "RiskConstraints", + "NotionalLimits", + "OrderIntent", + "NewOrderIntent", + "CancelOrderIntent", + "ReplaceOrderIntent", + "Price", + "Quantity", + "SlotKey", + "stable_slot_order_id", + "CandidateIntentOrigin", + "CandidateIntentRecord", + "ProcessingPosition", + "EventStreamEntry", + "process_canonical_event", + "process_event_entry", + "run_core_step", + "run_core_wakeup_reduction", + "run_core_wakeup_decision", + "run_core_wakeup_step", + "CoreStepStrategyContext", + "CoreStepStrategyEvaluator", + "CoreWakeupStrategyContext", + "CoreWakeupStrategyEvaluator", + "CoreExecutionControlApplyContext", + "CorePolicyAdmissionContext", + "CoreWakeupReductionResult", + "ExecutionControlDecision", + "PolicyIntentEvaluator", + "PolicyRiskDecision", + "PolicyRejectedCandidate", + "PolicyAdmissionResult", + "CoreStepDecision", + "CoreStepResult", + "ExecutionControl", + "ControlSchedulingObligation", + "NullEventBus", + "__version__", +) + 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", - "PolicyIntentEvaluator", - "PolicyRiskDecision", - "ExecutionControlDecision", - "CandidateIntentRecord", - "CandidateIntentOrigin", - "CorePolicyAdmissionContext", - "CoreExecutionControlApplyContext", - "ControlTimeEvent", - "MarketEvent", - "OrderSubmittedEvent", - "OrderExecutionFeedbackEvent", - "FillEvent", - "OrderIntent", - "NewOrderIntent", - "CancelOrderIntent", - "ReplaceOrderIntent", - "Price", - "Quantity", - "CoreConfiguration", - "StrategyState", - "ExecutionControl", - "ControlSchedulingObligation", - "NullEventBus", - "RiskEngine", - "RiskConfig", - ): + for symbol in EXPECTED_ROOT_EXPORTS: assert hasattr(tc, symbol), symbol +def test_public_api_docs_mention_root_exports() -> None: + docs_path = Path(__file__).resolve().parents[2] / "docs" / "reference" / "public-api.md" + docs_content = docs_path.read_text(encoding="utf-8") + documented_symbols = set(re.findall(r"`([A-Za-z_][A-Za-z0-9_]*)`", docs_content)) + + missing = sorted(symbol for symbol in EXPECTED_ROOT_EXPORTS if symbol not in documented_symbols) + assert missing == [], f"Root exports missing from public-api docs: {missing}" + + def test_public_api_does_not_expose_removed_compatibility_symbols() -> None: removed = ( "".join(["Gate", "Decision"]), diff --git a/tests/semantics/test_risk_engine_pipeline_integration.py b/tests/semantics/test_risk_engine_pipeline_integration.py index 743a27c..303d844 100644 --- a/tests/semantics/test_risk_engine_pipeline_integration.py +++ b/tests/semantics/test_risk_engine_pipeline_integration.py @@ -75,7 +75,7 @@ def _prime_market(state: tc.StrategyState) -> None: def test_risk_engine_accepts_generated_intent_in_run_core_step() -> None: - """RiskEngine is a valid optional policy_evaluator for the full step Pipeline.""" + """RiskEngine is a valid optional policy_evaluator for the full step pipeline.""" state = tc.StrategyState(event_bus=tc.NullEventBus()) _prime_market(state) policy_engine = tc.RiskEngine(_risk_config(trading_enabled=True)) @@ -109,7 +109,7 @@ def test_risk_engine_accepts_generated_intent_in_run_core_step() -> None: def test_risk_engine_rejects_generated_intent_when_trading_disabled() -> None: - """Trading-disabled RiskConfig rejects new intents through policy admission.""" + """Trading-disabled RiskConfig rejects new Intents through policy admission.""" state = tc.StrategyState(event_bus=tc.NullEventBus()) _prime_market(state) policy_engine = tc.RiskEngine(_risk_config(trading_enabled=False)) diff --git a/tests/semantics/test_runtime_core_contract.py b/tests/semantics/test_runtime_core_contract.py new file mode 100644 index 0000000..a0a1836 --- /dev/null +++ b/tests/semantics/test_runtime_core_contract.py @@ -0,0 +1,201 @@ +"""Focused Runtime/Core contract tests for E2 hardening.""" + +from __future__ import annotations + +import tradingchassis_core as tc + +INSTRUMENT = "BTC-USDC-PERP" +ORDER_ID = "runtime-contract-order-1" + + +def _submitted_entry(index: int, ts: int) -> tc.EventStreamEntry: + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=tc.OrderSubmittedEvent( + ts_ns_local_dispatch=ts, + instrument=INSTRUMENT, + client_order_id=ORDER_ID, + side="buy", + order_type="limit", + intended_price=tc.Price(currency="USDC", value=100.0), + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + time_in_force="GTC", + intent_correlation_id=None, + dispatch_attempt_id=None, + runtime_correlation=None, + ), + ) + + +def _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.25, + trading_volume=5.0, + trading_value=500.0, + num_trades=7, + runtime_correlation=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, + ), + ) + + +class _OneIntentEvaluator: + def evaluate(self, context: object) -> list[tc.NewOrderIntent]: + _ = context + return [ + tc.NewOrderIntent( + intent_type="new", + ts_ns_local=100, + instrument=INSTRUMENT, + client_order_id="intent-contract-1", + intents_correlation_id="corr-contract-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 + + +def test_order_execution_feedback_event_is_account_only() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + tc.process_event_entry(state, _submitted_entry(0, 100)) + state.mark_intent_sent(INSTRUMENT, ORDER_ID, "replace") + + assert state.has_working_order(INSTRUMENT, ORDER_ID) + assert state.has_inflight(INSTRUMENT, ORDER_ID) + assert INSTRUMENT not in state.fills + + projection_before = state.canonical_orders[(INSTRUMENT, ORDER_ID)] + projection_state_before = projection_before.state + projection_updated_before = projection_before.updated_ts_ns_local + + tc.process_event_entry(state, _feedback_entry(1, 101)) + + account = state.account[INSTRUMENT] + assert account.position == 2.5 + assert account.balance == 10_000.0 + assert account.fee == 0.25 + assert account.trading_volume == 5.0 + assert account.trading_value == 500.0 + assert account.num_trades == 7 + + assert state.has_working_order(INSTRUMENT, ORDER_ID) + assert state.has_inflight(INSTRUMENT, ORDER_ID) + assert INSTRUMENT not in state.fills + assert state.fill_cum_qty.get(INSTRUMENT) is None + + projection_after = state.canonical_orders[(INSTRUMENT, ORDER_ID)] + assert projection_after.state == projection_state_before + assert projection_after.updated_ts_ns_local == projection_updated_before + + +def test_control_scheduling_obligation_is_not_canonical_event() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + obligation = tc.ControlSchedulingObligation( + due_ts_ns_local=200, + reason="rate_limit", + scope_key=f"instrument:{INSTRUMENT}", + source="test", + ) + + try: + tc.process_canonical_event(state, obligation) + except TypeError as exc: + assert "Unsupported non-canonical Event type" in str(exc) + else: + raise AssertionError("ControlSchedulingObligation must not be accepted as canonical Event") + + tc.process_event_entry( + state, + tc.EventStreamEntry( + position=tc.ProcessingPosition(index=0), + event=tc.ControlTimeEvent( + ts_ns_local_control=200, + reason="scheduled_control_recheck", + due_ts_ns_local=200, + realized_ts_ns_local=200, + obligation_reason=obligation.reason, + obligation_due_ts_ns_local=obligation.due_ts_ns_local, + runtime_correlation=None, + ), + ), + ) + assert state.sim_ts_ns_local == 200 + + +def test_core_step_result_outputs_are_runtime_contract_envelope() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + policy_context = tc.CorePolicyAdmissionContext( + policy_evaluator=_AllowAllPolicy(), + now_ts_ns_local=100, + ) + + result_without_dispatchables = tc.run_core_step( + state, + _control_entry(0, 100), + strategy_evaluator=_OneIntentEvaluator(), + policy_admission_context=policy_context, + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=100, + activate_dispatchable_outputs=False, + ), + ) + + assert isinstance(result_without_dispatchables.generated_intents, tuple) + assert isinstance(result_without_dispatchables.candidate_intents, tuple) + assert isinstance(result_without_dispatchables.candidate_intent_records, tuple) + assert isinstance(result_without_dispatchables.dispatchable_intents, tuple) + assert result_without_dispatchables.generated_intents + assert result_without_dispatchables.candidate_intents + assert result_without_dispatchables.core_step_decision is not None + assert result_without_dispatchables.dispatchable_intents == () + + state2 = tc.StrategyState(event_bus=tc.NullEventBus()) + result_with_dispatchables = tc.run_core_step( + state2, + _control_entry(0, 100), + strategy_evaluator=_OneIntentEvaluator(), + policy_admission_context=policy_context, + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=100, + activate_dispatchable_outputs=True, + ), + ) + assert isinstance(result_with_dispatchables.dispatchable_intents, tuple) + assert len(result_with_dispatchables.dispatchable_intents) == 1 diff --git a/tests/semantics/test_strategy_state_view_boundary.py b/tests/semantics/test_strategy_state_view_boundary.py new file mode 100644 index 0000000..d07e394 --- /dev/null +++ b/tests/semantics/test_strategy_state_view_boundary.py @@ -0,0 +1,196 @@ +"""Strategy boundary tests for read-only Strategy State views.""" + +from __future__ import annotations + +import pytest + +import tradingchassis_core as tc + +INSTRUMENT = "BTC-USDC-PERP" +WORKING_ORDER_ID = "working-order-1" +FILL_ORDER_ID = "fill-order-1" +QUEUED_ORDER_ID = "queued-order-1" +INFLIGHT_ORDER_ID = "inflight-order-1" + + +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: int, client_order_id: str) -> tc.EventStreamEntry: + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=tc.OrderSubmittedEvent( + ts_ns_local_dispatch=ts, + instrument=INSTRUMENT, + client_order_id=client_order_id, + side="buy", + order_type="limit", + intended_price=tc.Price(currency="USDC", value=100.0), + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + time_in_force="GTC", + intent_correlation_id=None, + dispatch_attempt_id=None, + runtime_correlation=None, + ), + ) + + +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 _fill_entry(index: int, ts: int, client_order_id: str) -> tc.EventStreamEntry: + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=tc.FillEvent( + ts_ns_exch=ts - 1, + ts_ns_local=ts, + instrument=INSTRUMENT, + client_order_id=client_order_id, + side="buy", + filled_price=tc.Price(currency="USDC", value=100.0), + cum_filled_qty=tc.Quantity(value=1.0, unit="contracts"), + remaining_qty=tc.Quantity(value=0.0, unit="contracts"), + time_in_force="GTC", + liquidity_flag="taker", + ), + ) + + +def _queued_intent() -> tc.NewOrderIntent: + return tc.NewOrderIntent( + intent_type="new", + ts_ns_local=100, + instrument=INSTRUMENT, + client_order_id=QUEUED_ORDER_ID, + intents_correlation_id="queued-corr", + side="buy", + 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 _seed_state() -> tc.StrategyState: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + state.update_market( + instrument=INSTRUMENT, + 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=100, + ts_ns_exch=99, + ) + tc.process_event_entry(state, _execution_feedback_entry(0, 101)) + tc.process_event_entry(state, _order_submitted_entry(1, 102, WORKING_ORDER_ID)) + tc.process_event_entry(state, _fill_entry(2, 103, FILL_ORDER_ID)) + state.merge_intents_into_queue(INSTRUMENT, [_queued_intent()]) + state.mark_intent_sent(INSTRUMENT, INFLIGHT_ORDER_ID, "replace") + return state + + +def test_core_step_strategy_state_is_read_only_view() -> None: + state = _seed_state() + + class _ReadOnlyStepEvaluator: + def evaluate(self, context: tc.CoreStepStrategyContext) -> list[tc.OrderIntent]: + assert isinstance(context.state, tc.StrategyStateView) + assert context.state.sim_ts_ns_local >= 103 + assert context.state.market[INSTRUMENT].best_bid == 99.0 + assert context.state.account[INSTRUMENT].position == 2.5 + assert context.state.orders[INSTRUMENT][WORKING_ORDER_ID].client_order_id == WORKING_ORDER_ID + assert len(context.state.fills[INSTRUMENT]) == 1 + assert context.state.fill_cum_qty[INSTRUMENT][FILL_ORDER_ID] == 1.0 + + with pytest.raises(TypeError): + context.state.market[INSTRUMENT] = context.state.market[INSTRUMENT] # type: ignore[index] + with pytest.raises(AttributeError): + context.state.market[INSTRUMENT].best_bid = 123.0 # type: ignore[misc] + with pytest.raises(TypeError): + context.state.fill_cum_qty[INSTRUMENT][FILL_ORDER_ID] = 2.0 # type: ignore[index] + + copied_fill = context.state.fills[INSTRUMENT][0] + copied_fill.side = "sell" + + assert not hasattr(context.state, "update_market") + assert not hasattr(context.state, "update_account") + assert not hasattr(context.state, "apply_fill_event") + assert not hasattr(context.state, "apply_order_submitted_event") + assert not hasattr(context.state, "apply_order_execution_feedback_event") + assert not hasattr(context.state, "apply_control_time_event") + assert not hasattr(context.state, "merge_intents_into_queue") + assert not hasattr(context.state, "pop_queued_intents_for_order") + assert not hasattr(context.state, "mark_intent_sent") + assert not hasattr(context.state, "_advance_processing_position") + assert not hasattr(context.state, "queued_intents") + assert not hasattr(context.state, "inflight") + return [] + + _ = tc.run_core_step( + state, + _control_entry(3, 104), + strategy_evaluator=_ReadOnlyStepEvaluator(), + ) + + assert state.market[INSTRUMENT].best_bid == 99.0 + assert state.account[INSTRUMENT].position == 2.5 + assert state.orders[INSTRUMENT][WORKING_ORDER_ID].intended_price == 100.0 + assert state.fills[INSTRUMENT][0].side == "buy" + assert state.fill_cum_qty[INSTRUMENT][FILL_ORDER_ID] == 1.0 + assert state.has_queued_intent(INSTRUMENT, QUEUED_ORDER_ID) + assert state.has_inflight(INSTRUMENT, INFLIGHT_ORDER_ID) + + +def test_core_wakeup_strategy_state_is_read_only_view() -> None: + state = _seed_state() + + class _ReadOnlyWakeupEvaluator: + def evaluate(self, context: tc.CoreWakeupStrategyContext) -> list[tc.OrderIntent]: + assert isinstance(context.state, tc.StrategyStateView) + assert len(context.entries) == 2 + assert context.last_position is not None + assert context.last_position.index == 5 + assert context.state.market[INSTRUMENT].best_ask == 101.0 + with pytest.raises(AttributeError): + setattr( + context.state.orders[INSTRUMENT][WORKING_ORDER_ID], + "state", + "cancelled", + ) + return [] + + _ = tc.run_core_wakeup_step( + state, + (_control_entry(4, 105), _control_entry(5, 106)), + wakeup_strategy_evaluator=_ReadOnlyWakeupEvaluator(), + ) diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index 52c928b..276f385 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -43,7 +43,7 @@ SlotKey, stable_slot_order_id, ) -from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.state import StrategyState, StrategyStateView 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 ( @@ -53,8 +53,11 @@ MarketEvent, NewOrderIntent, NotionalLimits, + OrderCanceledEvent, OrderExecutionFeedbackEvent, + OrderExpiredEvent, OrderIntent, + OrderRejectedEvent, OrderSubmittedEvent, Price, Quantity, @@ -72,9 +75,13 @@ "RiskConfig", "RiskEngine", "StrategyState", + "StrategyStateView", "MarketEvent", "ControlTimeEvent", "OrderSubmittedEvent", + "OrderCanceledEvent", + "OrderRejectedEvent", + "OrderExpiredEvent", "OrderExecutionFeedbackEvent", "FillEvent", "RiskConstraints", diff --git a/tradingchassis_core/core/domain/candidate_intent.py b/tradingchassis_core/core/domain/candidate_intent.py index 4f75467..4822f6c 100644 --- a/tradingchassis_core/core/domain/candidate_intent.py +++ b/tradingchassis_core/core/domain/candidate_intent.py @@ -9,7 +9,7 @@ class CandidateIntentOrigin(str, Enum): - """Origin marker for candidate intents in one Core step.""" + """Origin marker for candidate Intents in one Core step.""" GENERATED = "generated" QUEUED = "queued" diff --git a/tradingchassis_core/core/domain/event_model.py b/tradingchassis_core/core/domain/event_model.py index f2afe8a..ba22b02 100644 --- a/tradingchassis_core/core/domain/event_model.py +++ b/tradingchassis_core/core/domain/event_model.py @@ -8,7 +8,10 @@ ControlTimeEvent, FillEvent, MarketEvent, + OrderCanceledEvent, OrderExecutionFeedbackEvent, + OrderExpiredEvent, + OrderRejectedEvent, OrderSubmittedEvent, ) from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation @@ -30,6 +33,9 @@ class CanonicalEventCategory(str, Enum): CANONICAL_STREAM_CANDIDATE_CATEGORY_BY_TYPE: dict[type[object], CanonicalEventCategory] = { MarketEvent: CanonicalEventCategory.MARKET, OrderSubmittedEvent: CanonicalEventCategory.INTENT_RELATED, + OrderCanceledEvent: CanonicalEventCategory.EXECUTION, + OrderRejectedEvent: CanonicalEventCategory.EXECUTION, + OrderExpiredEvent: CanonicalEventCategory.EXECUTION, FillEvent: CanonicalEventCategory.EXECUTION, OrderExecutionFeedbackEvent: CanonicalEventCategory.EXECUTION, ControlTimeEvent: CanonicalEventCategory.CONTROL, diff --git a/tradingchassis_core/core/domain/intent_combination.py b/tradingchassis_core/core/domain/intent_combination.py index 4ae2d88..2e8e803 100644 --- a/tradingchassis_core/core/domain/intent_combination.py +++ b/tradingchassis_core/core/domain/intent_combination.py @@ -40,7 +40,7 @@ def combine_candidate_intent_records( generated_intents: Sequence[OrderIntent], queued_intents: Sequence[OrderIntent], ) -> tuple[CandidateIntentRecord, ...]: - """Combine queued + generated intents into a deterministic effective set. + """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. diff --git a/tradingchassis_core/core/domain/processing.py b/tradingchassis_core/core/domain/processing.py index b7303ec..de006fa 100644 --- a/tradingchassis_core/core/domain/processing.py +++ b/tradingchassis_core/core/domain/processing.py @@ -29,7 +29,10 @@ ControlTimeEvent, FillEvent, MarketEvent, + OrderCanceledEvent, OrderExecutionFeedbackEvent, + OrderExpiredEvent, + OrderRejectedEvent, OrderSubmittedEvent, ) @@ -110,6 +113,9 @@ def process_canonical_event( Accepted canonical candidates in the current slice: - ``MarketEvent`` (category: ``market``) - ``OrderSubmittedEvent`` (category: ``intent_related``) + - ``OrderCanceledEvent`` (category: ``execution``) + - ``OrderRejectedEvent`` (category: ``execution``) + - ``OrderExpiredEvent`` (category: ``execution``) - ``FillEvent`` (category: ``execution``) - ``OrderExecutionFeedbackEvent`` (category: ``execution``) - ``ControlTimeEvent`` (category: ``control``) @@ -128,15 +134,22 @@ def process_canonical_event( category = canonical_category_for_type(record_type) if category == CanonicalEventCategory.MARKET and isinstance(event, MarketEvent): - if not event.is_book() or event.book is None: + if event.is_trade(): raise ValueError( - "Unsupported MarketEvent payload for canonical processing: " - "book snapshot/delta with top-of-book levels is required." + "Unsupported MarketEvent for canonical processing in the current Core " + "baseline: only book MarketEvent payloads are reduced; trade-shaped " + "MarketEvent payloads are not supported." + ) + if event.book is None: + raise ValueError( + "Unsupported MarketEvent payload for canonical processing in the current " + "Core baseline: book payload is required." ) if not event.book.bids or not event.book.asks: raise ValueError( - "Unsupported MarketEvent payload for canonical processing: " - "book payload must include at least one bid and one ask level." + "Unsupported MarketEvent payload for canonical processing in the current " + "Core baseline: book payload must include at least one bid and one ask " + "level." ) best_bid_level = event.book.bids[0] @@ -181,6 +194,24 @@ def process_canonical_event( state.apply_fill_event(event) return + if category == CanonicalEventCategory.EXECUTION and isinstance(event, OrderCanceledEvent): + if position is not None: + state._advance_processing_position(position) + state.apply_order_canceled_event(event) + return + + if category == CanonicalEventCategory.EXECUTION and isinstance(event, OrderRejectedEvent): + if position is not None: + state._advance_processing_position(position) + state.apply_order_rejected_event(event) + return + + if category == CanonicalEventCategory.EXECUTION and isinstance(event, OrderExpiredEvent): + if position is not None: + state._advance_processing_position(position) + state.apply_order_expired_event(event) + return + if ( category == CanonicalEventCategory.EXECUTION and isinstance(event, OrderExecutionFeedbackEvent) diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index 711c957..44228b4 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -27,7 +27,7 @@ ) 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.state import StrategyState, StrategyStateView 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 @@ -40,7 +40,7 @@ class CoreStepStrategyContext: """Deterministic Strategy-evaluation context for one Core step.""" - state: StrategyState + state: StrategyStateView event: object position: ProcessingPosition configuration: CoreConfiguration | None = None @@ -57,7 +57,7 @@ def evaluate(self, context: CoreStepStrategyContext) -> Sequence[OrderIntent]: class CoreWakeupStrategyContext: """Deterministic Strategy-evaluation context for one Core wakeup batch.""" - state: StrategyState + state: StrategyStateView entries: tuple[EventStreamEntry, ...] configuration: CoreConfiguration | None = None last_position: ProcessingPosition | None = None @@ -147,8 +147,9 @@ def run_core_step( generated_intents: tuple[OrderIntent, ...] = () if strategy_evaluator is not None: + state_view = StrategyStateView(state) strategy_context = CoreStepStrategyContext( - state=state, + state=state_view, event=entry.event, position=entry.position, configuration=configuration, @@ -243,8 +244,9 @@ def run_core_wakeup_reduction( generated_intents: tuple[OrderIntent, ...] = () if wakeup_strategy_evaluator is not None: last_position = entries_tuple[-1].position if entries_tuple else None + state_view = StrategyStateView(state) wakeup_context = CoreWakeupStrategyContext( - state=state, + state=state_view, entries=entries_tuple, configuration=configuration, last_position=last_position, diff --git a/tradingchassis_core/core/domain/state.py b/tradingchassis_core/core/domain/state.py index 06a0821..70a8c9d 100644 --- a/tradingchassis_core/core/domain/state.py +++ b/tradingchassis_core/core/domain/state.py @@ -9,7 +9,8 @@ from collections import deque from dataclasses import dataclass -from typing import TYPE_CHECKING, Iterable +from types import MappingProxyType +from typing import TYPE_CHECKING, Iterable, Mapping from tradingchassis_core.core.domain.processing_order import ProcessingPosition @@ -18,8 +19,11 @@ ControlTimeEvent, FillEvent, NewOrderIntent, + OrderCanceledEvent, OrderExecutionFeedbackEvent, + OrderExpiredEvent, OrderIntent, + OrderRejectedEvent, OrderSubmittedEvent, ) from tradingchassis_core.core.events.event_bus import EventBus @@ -104,6 +108,189 @@ class CanonicalOrderProjection: intended_qty: float | None = None +@dataclass(frozen=True, slots=True) +class MarketStateView: + """Immutable market snapshot exposed to Strategy evaluation.""" + + last_ts_ns_local: int + last_ts_ns_exch: int + best_bid: float + best_ask: float + mid: float + best_bid_qty: float + best_ask_qty: float + tick_size: float + lot_size: float + contract_size: float + + +@dataclass(frozen=True, slots=True) +class AccountStateView: + """Immutable account snapshot exposed to Strategy evaluation.""" + + position: float + balance: float + fee: float + trading_volume: float + trading_value: float + num_trades: int + equity: float + initial_equity: float + realized_pnl: float + + +@dataclass(frozen=True, slots=True) +class WorkingOrderView: + """Immutable working-order snapshot exposed to Strategy evaluation.""" + + 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 + + +class StrategyStateView: + """Read-only snapshot of Strategy State for Strategy evaluation.""" + + __slots__ = ( + "_sim_ts_ns_local", + "_market", + "_account", + "_orders", + "_fills", + "_fill_cum_qty", + ) + + def __init__(self, state: StrategyState) -> None: + self._sim_ts_ns_local = state.sim_ts_ns_local + + market_snapshot = { + instrument: MarketStateView( + last_ts_ns_local=market.last_ts_ns_local, + last_ts_ns_exch=market.last_ts_ns_exch, + best_bid=market.best_bid, + best_ask=market.best_ask, + mid=market.mid, + best_bid_qty=market.best_bid_qty, + best_ask_qty=market.best_ask_qty, + tick_size=market.tick_size, + lot_size=market.lot_size, + contract_size=market.contract_size, + ) + for instrument, market in state.market.items() + } + self._market: Mapping[str, MarketStateView] = MappingProxyType(market_snapshot) + + account_snapshot = { + instrument: AccountStateView( + position=account.position, + balance=account.balance, + fee=account.fee, + trading_volume=account.trading_volume, + trading_value=account.trading_value, + num_trades=account.num_trades, + equity=account.equity, + initial_equity=account.initial_equity, + realized_pnl=account.realized_pnl, + ) + for instrument, account in state.account.items() + } + self._account: Mapping[str, AccountStateView] = MappingProxyType(account_snapshot) + + orders_snapshot: dict[str, Mapping[str, WorkingOrderView]] = {} + for instrument, by_id in state.orders.items(): + orders_snapshot[instrument] = MappingProxyType( + { + client_order_id: WorkingOrderView( + instrument=working.instrument, + client_order_id=working.client_order_id, + side=working.side, + intended_price=working.intended_price, + intended_qty=working.intended_qty, + cum_filled_qty=working.cum_filled_qty, + remaining_qty=working.remaining_qty, + state=working.state, + submitted_ts_ns_local=working.submitted_ts_ns_local, + updated_ts_ns_local=working.updated_ts_ns_local, + ) + for client_order_id, working in by_id.items() + } + ) + self._orders: Mapping[str, Mapping[str, WorkingOrderView]] = MappingProxyType( + orders_snapshot + ) + + fills_snapshot = { + instrument: tuple(fill.model_copy(deep=True) for fill in fills) + for instrument, fills in state.fills.items() + } + self._fills: Mapping[str, tuple[FillEvent, ...]] = MappingProxyType(fills_snapshot) + + fill_cum_snapshot: dict[str, Mapping[str, float]] = {} + for instrument, fill_cum_by_id in state.fill_cum_qty.items(): + fill_cum_snapshot[instrument] = MappingProxyType( + { + client_order_id: float(qty) + for client_order_id, qty in fill_cum_by_id.items() + } + ) + self._fill_cum_qty: Mapping[str, Mapping[str, float]] = MappingProxyType( + fill_cum_snapshot + ) + + @property + def sim_ts_ns_local(self) -> int: + return self._sim_ts_ns_local + + @property + def market(self) -> Mapping[str, MarketStateView]: + return self._market + + @property + def account(self) -> Mapping[str, AccountStateView]: + return self._account + + @property + def orders(self) -> Mapping[str, Mapping[str, WorkingOrderView]]: + return self._orders + + @property + def fills(self) -> Mapping[str, tuple[FillEvent, ...]]: + return self._fills + + @property + def fill_cum_qty(self) -> Mapping[str, Mapping[str, float]]: + return self._fill_cum_qty + + def get_mid(self, instrument: str) -> float: + market = self._market.get(instrument) + return 0.0 if market is None else market.mid + + def get_contract_size(self, instrument: str) -> float: + market = self._market.get(instrument) + return 1.0 if market is None else market.contract_size + + def get_tick_size(self, instrument: str) -> float: + market = self._market.get(instrument) + return 0.0 if market is None else market.tick_size + + def get_lot_size(self, instrument: str) -> float: + market = self._market.get(instrument) + return 0.0 if market is None else market.lot_size + + def get_total_equity(self) -> float: + return float(sum(account.equity for account in self._account.values())) + + def get_total_pnl(self) -> float: + return float(sum(account.realized_pnl for account in self._account.values())) + + class StrategyState: """High-level deterministic Strategy state keyed by instrument.""" @@ -212,6 +399,66 @@ def apply_order_submitted_event(self, event: OrderSubmittedEvent) -> None: ) self._clear_inflight(event.instrument, event.client_order_id) + def _apply_terminal_order_event( + self, + *, + instrument: str, + client_order_id: str, + ts_ns_local_feedback: int, + terminal_state: str, + ) -> None: + self.update_timestamp(ts_ns_local_feedback) + + order_bucket = self.orders.get(instrument) + if order_bucket is not None: + order_bucket.pop(client_order_id, None) + + key = (instrument, client_order_id) + projection = self.canonical_orders.get(key) + if projection is None: + projection = CanonicalOrderProjection( + instrument=instrument, + client_order_id=client_order_id, + state=terminal_state, + submitted_ts_ns_local=ts_ns_local_feedback, + updated_ts_ns_local=ts_ns_local_feedback, + ) + self.canonical_orders[key] = projection + + projection.state = terminal_state + projection.updated_ts_ns_local = max( + projection.updated_ts_ns_local, ts_ns_local_feedback + ) + + self._clear_inflight(instrument, client_order_id) + + def apply_order_canceled_event(self, event: OrderCanceledEvent) -> None: + """Reduce canonical canceled-order feedback into terminal order projection.""" + self._apply_terminal_order_event( + instrument=event.instrument, + client_order_id=event.client_order_id, + ts_ns_local_feedback=event.ts_ns_local_feedback, + terminal_state="canceled", + ) + + def apply_order_rejected_event(self, event: OrderRejectedEvent) -> None: + """Reduce canonical rejected-order feedback into terminal order projection.""" + self._apply_terminal_order_event( + instrument=event.instrument, + client_order_id=event.client_order_id, + ts_ns_local_feedback=event.ts_ns_local_feedback, + terminal_state="rejected", + ) + + def apply_order_expired_event(self, event: OrderExpiredEvent) -> None: + """Reduce canonical expired-order feedback into terminal order projection.""" + self._apply_terminal_order_event( + instrument=event.instrument, + client_order_id=event.client_order_id, + ts_ns_local_feedback=event.ts_ns_local_feedback, + terminal_state="expired", + ) + 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) diff --git a/tradingchassis_core/core/domain/types.py b/tradingchassis_core/core/domain/types.py index f3261cc..fb26370 100644 --- a/tradingchassis_core/core/domain/types.py +++ b/tradingchassis_core/core/domain/types.py @@ -214,6 +214,27 @@ class OrderSubmittedEvent(BaseModel): model_config = ConfigDict(extra="forbid") +class OrderCanceledEvent(BaseModel): + ts_ns_local_feedback: int = Field(..., gt=0) + instrument: str = Field(..., min_length=1) + client_order_id: str = Field(..., min_length=1) + model_config = ConfigDict(extra="forbid") + + +class OrderRejectedEvent(BaseModel): + ts_ns_local_feedback: int = Field(..., gt=0) + instrument: str = Field(..., min_length=1) + client_order_id: str = Field(..., min_length=1) + model_config = ConfigDict(extra="forbid") + + +class OrderExpiredEvent(BaseModel): + ts_ns_local_feedback: int = Field(..., gt=0) + instrument: str = Field(..., min_length=1) + client_order_id: str = Field(..., min_length=1) + model_config = ConfigDict(extra="forbid") + + class ControlTimeEvent(BaseModel): ts_ns_local_control: int = Field(..., gt=0) reason: str = Field(..., min_length=1) diff --git a/tradingchassis_core/core/execution_control/execution_control.py b/tradingchassis_core/core/execution_control/execution_control.py index 4dd3dd7..f008cfa 100644 --- a/tradingchassis_core/core/execution_control/execution_control.py +++ b/tradingchassis_core/core/execution_control/execution_control.py @@ -249,7 +249,7 @@ def handle_cancel_against_queued_only_state( replaced_in_queue: list[tuple[OrderIntent, OrderIntent]], handled_in_queue: list[OrderIntent], ) -> bool: - """CANCEL against queued-only state: remove queued intents, do not send cancel.""" + """CANCEL against queued-only state: remove queued Intents, do not send cancel.""" if it.intent_type != "cancel": return False diff --git a/tradingchassis_core/core/risk/execution_constraints_policy.py b/tradingchassis_core/core/risk/execution_constraints_policy.py index 5a08003..bf50d11 100644 --- a/tradingchassis_core/core/risk/execution_constraints_policy.py +++ b/tradingchassis_core/core/risk/execution_constraints_policy.py @@ -1,6 +1,6 @@ """Execution-constraint normalization and validation logic. -This module applies instrument/execution constraints to order intents, such as +This module applies instrument/execution constraints to order Intents, such as tick/lot rounding, post-only enforcement, and minimum notional checks. """ diff --git a/tradingchassis_core/core/risk/risk_policy.py b/tradingchassis_core/core/risk/risk_policy.py index 83a0e69..1cad450 100644 --- a/tradingchassis_core/core/risk/risk_policy.py +++ b/tradingchassis_core/core/risk/risk_policy.py @@ -111,7 +111,7 @@ def normalize_intent(self, it: OrderIntent, state: StrategyState) -> Normalizati def validate_intent(self, it: OrderIntent, state: StrategyState) -> tuple[bool, str]: """Outbound Intent sanity. - Even if your schemas allow 0 placeholders, outbound intents should still be sensible. + Even if your schemas allow 0 placeholders, outbound Intents should still be sensible. """ if it.ts_ns_local <= 0: return False, RejectReason.INVALID_TS @@ -229,14 +229,14 @@ def portfolio_gross_notional(self, state: StrategyState) -> float | None: return total def quote_book_global(self, state: StrategyState) -> dict[tuple[str, str], tuple[float, float]]: - """Build a best-effort global quote book including queued intents. + """Build a best-effort global quote book including queued Intents. Returns: Mapping (instrument, client_order_id) -> (abs_notional, signed_notional) Notes: - Working orders are sourced from StrategyState.orders. - - Queued intents in StrategyState.queued_intents are applied on top. + - Queued Intents in StrategyState.queued_intents are applied on top. - This is used only for quote-limits enforcement. """ @@ -254,7 +254,7 @@ def quote_book_global(self, state: StrategyState) -> dict[tuple[str, str], tuple signed = notional if o.side == "buy" else -notional book[(instr, oid)] = (notional, signed) - # Queued intents (apply on top of working) + # Queued Intents (apply on top of working) for instr, q in state.queued_intents.items(): contract_size = state.get_contract_size(instr) for qi in q: