From dc508e9737742a572d72cdce3a7c4185e7f3fdfe Mon Sep 17 00:00:00 2001 From: VarunSingh022 Date: Mon, 26 Oct 2026 13:55:04 +0530 Subject: [PATCH 01/56] feat: integrate research workflows and a global derivative lifecycle (v4.2.0 candidate) The v4.1.1 future-readiness program, unreleased; the version stays 4.1.0. Schedule and inputs on every driver - One RunSchedule for every timed input (FUT-004): rates, funding, cash flows, distributions, funding rates, splits, observations, timers, and now exercise, assignment, expiry and settlement. Entries sit before or after an instant's records, and ties break by kind. - A paused replay keeps outstanding information durably (FUT-003). The schedule is recorded in the run manifest (FUT-005). - Observations and several combined datasets on backtest and replay (combine_datasets, OFE-027/OFE-028). The macro engine's series arrive as observations (FUT-008). - Features and a fitted model on the execution path (FUT-006/FUT-007, examples 72 and 73). Bonds (FUT-010) and contract expiry with cash settlement (FUT-012). FUT-009: execution algorithms on the historical drivers - An ExecutionPlan given to BacktestEngine.run, ReplayBacktest.run or the facade routes the run ALGORITHMIC. Each parent is accepted and held whole, and stop, auction and immediate terms are refused. - Children are released per asset event and checked against a VenueCheck. Each one fills through ExecutionPipeline.work_child under the run's fill policy, simulator and costs, and reconciles to its parent. - The AlgorithmBook persists in replay snapshot schema 2. The manifest records the plan id. - Added AlgorithmicResult, example 74 and benchmark_algorithmic_execution. FUT-013: a market-agnostic derivative lifecycle - InstrumentRecord.terms declares OptionTerms or FutureTerms (instrument schema 4, outside the identity key): - call or put, strike, AMERICAN or EUROPEAN with a window; - CASH or PHYSICAL settlement with a Deliverable; - an automatic-exercise threshold. - Exercise, Assignment and OptionExpiry run as schedule entries and as RunEngine/ExecutionPipeline operations. - Cash settlement pays the intrinsic value against a supplied settlement value. - Physical delivery moves the deliverable at strike x multiplier through the portfolio's fill authority. It is apportioned across strategies by largest remainder. - A physical future delivers at its final price. - Refused before anything moves: - an undeclared or other-currency deliverable; - an unfunded payment; - a forbidden short; - out-of-window or over-position exercise; - working orders; - a missing or extraneous settlement value. - AssetType.INDEX is a reference value that cannot be traded. - Calendars gain session_close and settles_at, for any declared market and timezone. Added example 75. Governance - Ledger: FUT-009 and FUT-013 delivered; FUT-014..FUT-017 and TST-023 recorded. - Also updated: ROADMAP, CHANGELOG [Unreleased], ADR-0051, the capability matrix (2.27 global conventions), the gaps report, FUTURE_READINESS_V4_1_1, the public API manifest (2,667 exports) and the release certification (11 checks). Validation (local; hosted CI not run) - ruff, format and mypy (1,208 files) are clean. - Checkout: 10,020 passed, 3 failed. - Fresh CPython 3.12, 3.13 and 3.14: 10,021 passed, 2 failed each. - 75/75 examples; build, twine --strict and preflight passed. - Mutation checks: 12/12 (FUT-013) and 8/8 (FUT-009) caught. - The failures are expected until the version is bumped: test_api_changes_are_in_the_changelog and test_no_ledger_entry_is_assigned_beyond_this_release are version-keyed; test_package_metadata fails on the stale editable metadata of the dev venv only. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 143 +++ README.md | 2 +- ROADMAP.md | 88 ++ alphalab/allocation/engine.py | 104 ++ alphalab/api.py | 420 +++++- alphalab/backtesting/__init__.py | 47 +- alphalab/backtesting/algorithmic.py | 1125 +++++++++++++++++ alphalab/backtesting/engine.py | 97 +- alphalab/backtesting/replay.py | 116 +- alphalab/backtesting/replay_snapshot.py | 128 +- alphalab/backtesting/schedule.py | 613 +++++++++ alphalab/backtesting/state.py | 34 +- alphalab/core/enums.py | 9 + alphalab/instrument/__init__.py | 12 + alphalab/instrument/economics.py | 5 + alphalab/instrument/record.py | 67 + alphalab/instrument/registry.py | 23 + alphalab/instrument/snapshot.py | 55 +- alphalab/instrument/terms.py | 352 ++++++ alphalab/lifecycle/reproducibility.py | 214 +++- alphalab/portfolio/__init__.py | 8 + alphalab/portfolio/corporate_actions.py | 154 ++- alphalab/portfolio/engine.py | 11 +- alphalab/reinforcement_learning/__init__.py | 6 +- .../reinforcement_learning/environment.py | 39 +- alphalab/runtime/__init__.py | 4 +- alphalab/runtime/assumptions.py | 7 +- alphalab/runtime/calendars.py | 61 +- alphalab/runtime/context_views.py | 29 +- alphalab/runtime/execution_pipeline.py | 1019 ++++++++++++++- alphalab/runtime/live.py | 33 + alphalab/runtime/run.py | 165 ++- benchmarks/benchmark_algorithmic_execution.py | 209 +++ ...river-and-the-strategy-capability-audit.md | 269 ++++ docs/ARCHITECTURE.md | 15 +- docs/COMPOSING_STRATEGIES.md | 265 ++++ docs/EXAMPLES.md | 45 +- docs/INTEGRATION.md | 2 +- docs/README.md | 1 + docs/STATE_MODEL.md | 19 +- docs/api/PUBLIC_API.md | 2 +- docs/api/public_api.json | 73 +- docs/audit/FUTURE_READINESS_V4_1_1.md | 426 +++++++ docs/audit/PRE_V4_COMPLETION_LEDGER.yaml | 408 +++++- docs/audit/RELEASE_CERTIFICATION.md | 8 +- docs/audit/STRATEGY_CAPABILITY_MATRIX.md | 618 +++++++++ .../STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md | 170 +++ docs/audit/release_certification.json | 8 +- .../generate_v4_1_0_snapshot_fixtures.py | 5 +- examples/72_compose_a_multi_asset_strategy.py | 1046 +++++++++++++++ examples/73_train_and_trade_a_model.py | 454 +++++++ .../74_evaluate_an_execution_algorithm.py | 458 +++++++ examples/75_exercise_assign_and_deliver.py | 457 +++++++ examples/README.md | 6 +- nowandfuture.md | 8 +- tests/installed_smoke.py | 14 +- .../test_algorithms_on_the_historical_path.py | 993 +++++++++++++++ tests/integration/test_bonds_on_the_path.py | 163 +++ .../integration/test_classification_limits.py | 2 +- tests/integration/test_contract_expiry.py | 430 +++++++ .../test_execution_algorithm_example.py | 74 ++ .../integration/test_lifecycle_conventions.py | 325 +++++ tests/integration/test_lifecycle_example.py | 87 ++ .../test_model_on_the_execution_path.py | 203 +++ .../test_multi_asset_composition.py | 625 +++++++++ tests/integration/test_option_lifecycle.py | 817 ++++++++++++ tests/integration/test_scheduled_inputs.py | 466 +++++++ .../test_documented_schemas_are_current.py | 3 + .../test_every_adr_deferral_is_classified.py | 17 + ...st_heterogeneous_inputs_on_every_driver.py | 485 +++++++ .../test_no_silent_financial_defaults.py | 3 + tests/regression/test_persisted_enum_names.py | 19 +- .../test_replay_pause_and_resume.py | 22 +- tests/regression/test_schema_upgrades.py | 5 +- .../regression/test_schema_upgrades_v3_11.py | 5 +- .../regression/test_schema_upgrades_v3_12.py | 24 +- .../regression/test_schema_upgrades_v3_13.py | 24 +- ...ctor_classification_reaches_attribution.py | 10 +- .../test_strategy_context_reads_bars.py | 99 ++ tests/regression/test_v37_invariants.py | 5 +- tests/regression/test_v38_invariants.py | 5 +- tests/regression/test_v39_invariants.py | 87 +- tests/unit/lifecycle/test_reproducibility.py | 73 ++ tests/unit/test_api_combined_datasets.py | 182 +++ tests/unit/test_api_macro_lift.py | 98 ++ 85 files changed, 15351 insertions(+), 176 deletions(-) create mode 100644 alphalab/backtesting/algorithmic.py create mode 100644 alphalab/backtesting/schedule.py create mode 100644 alphalab/instrument/terms.py create mode 100644 benchmarks/benchmark_algorithmic_execution.py create mode 100644 docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md create mode 100644 docs/COMPOSING_STRATEGIES.md create mode 100644 docs/audit/FUTURE_READINESS_V4_1_1.md create mode 100644 docs/audit/STRATEGY_CAPABILITY_MATRIX.md create mode 100644 docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md create mode 100644 examples/72_compose_a_multi_asset_strategy.py create mode 100644 examples/73_train_and_trade_a_model.py create mode 100644 examples/74_evaluate_an_execution_algorithm.py create mode 100644 examples/75_exercise_assign_and_deliver.py create mode 100644 tests/integration/test_algorithms_on_the_historical_path.py create mode 100644 tests/integration/test_bonds_on_the_path.py create mode 100644 tests/integration/test_contract_expiry.py create mode 100644 tests/integration/test_execution_algorithm_example.py create mode 100644 tests/integration/test_lifecycle_conventions.py create mode 100644 tests/integration/test_lifecycle_example.py create mode 100644 tests/integration/test_model_on_the_execution_path.py create mode 100644 tests/integration/test_multi_asset_composition.py create mode 100644 tests/integration/test_option_lifecycle.py create mode 100644 tests/integration/test_scheduled_inputs.py create mode 100644 tests/regression/test_heterogeneous_inputs_on_every_driver.py create mode 100644 tests/regression/test_strategy_context_reads_bars.py create mode 100644 tests/unit/test_api_combined_datasets.py create mode 100644 tests/unit/test_api_macro_lift.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bc995170..fd7cf053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,149 @@ changed. The current state of the project is in `README.md`, `ROADMAP.md` and --- +# [Unreleased] - v4.1.1 candidate (release number pending the maintainer's decision; recommended 4.2.0) + +**A researcher-defined strategy over several assets, datasets, sentiment, macro +releases and fitted models -- every timed input applied at its instant on every +driver, contracts that expire, are exercised, assigned and delivered by their +declared terms in any market a calendar describes, bonds that pay coupons and +redeem, orders worked in children by execution algorithms over historical bars, +a paused replay that keeps all of it durably -- and a source-derived audit of what +AlphaLab supports from research to production.** + +Additive on the v4.1 contract: against v4.1.0's manifest it adds names, gives +existing functions keyword-only parameters with no effect unless given, adds +`AssetType.BOND`, `AssetType.INDEX` and `ExecutionRouting.ALGORITHMIC`, and +removes or re-signs nothing incompatibly. Two schemas step, +each reading every earlier payload: instrument 3 -> 4 (a last trading instant +and contract terms) and replay 1 -> 2 (the schedule's digest and an execution +plan's book). No run, pipeline or portfolio schema +moves, and a run of v4.1.0's inputs has the identity it had. Because it adds +public API it is a minor release under `docs/api/PUBLIC_API.md`'s policy -- see +`docs/audit/FUTURE_READINESS_V4_1_1.md`. ADR-0051 records the decisions; ledger +IDs are in brackets. + +## Added + +* **Every timed input on the historical drivers** [FUT-004, OFE-027]. + `alphalab.backtesting.schedule`: a `RunSchedule` of observations, FX rates, + fundings, cash flows, per-unit distributions (`ScheduledDistribution`), + perpetual funding rates (`ScheduledFundingRate`), splits, timers and contract + settlements, applied by one `ScheduleCursor` in `BacktestEngine.run(..., + schedule=)`, `ReplayBacktest.run(..., observations=, schedule=)`, + `alphalab.api.backtest`/`replay` and any loop written by hand -- each input + before or after the records of its instant, the rate table converting every + step. A book settling two currencies, an `on_timer` strategy, a dividend sized + by the position, a perpetual's funding and a split no longer need a hand loop. + `RunEngine.fund`. +* **A paused replay with information outstanding, kept durably** [FUT-003]. + Replay schema 2 records the schedule's digest; `replay_snapshot.restore(..., + schedule)` takes it back and refuses another or none. +* **Contracts expire and settle** [FUT-012]. `InstrumentRecord.last_trade_at`; + after it the instrument's records are skipped and its requests refused; + `ContractSettlement` (`RunEngine.settle_contract`, + `ExecutionPipeline.settle_contract`) closes every position at the final price + -- a future's last variation, an option's intrinsic value, zero for one + expiring worthless (`PortfolioEngine.apply_fill(..., final_settlement=True)`) + -- cancels working orders and attributes the settlement + (`AllocationEngine.settle_asset`); a run that ends holding an expired contract + is refused. +* **Bonds** [FUT-010]. `AssetType.BOND`, booked by declared economics; + `alphalab.api.bond_schedule` turns a `macro.bond.Bond` into per-unit coupons + and a redemption settlement. +* **Several datasets in one run** [OFE-028]. `alphalab.api.CombinedDataset`, + `CombinedProvenance`, `CombinedSourceBytes`, `combine_datasets`, + `combined_market_dataset`. +* **The schedule in a run's manifest** [FUT-005]. + `manifest_for_run(..., schedule=)` checks it against the run's deliveries and + records `ReproducibilityManifest.schedule_digest`, rendered into the identity + only when given, so every earlier manifest keeps its id. A run over a combined + dataset records `ReproducibilityManifest.dataset_parts`, checked against the + combined identity and listed part by part by `external_requirements`. +* **Execution algorithms on the historical drivers** [FUT-009]. + `alphalab.backtesting.algorithmic`: an `ExecutionPlan` of + `AlgorithmAssignment`s (an algorithm, a window, a quantity increment), by + asset or for every asset, with an optional `VenueCheck`, given to + `BacktestEngine.run(..., execution=)`, `ReplayBacktest.run(..., execution=)` + or `alphalab.api.backtest`/`replay`. The run routes + `ExecutionRouting.ALGORITHMIC`: each parent is accepted and held, worked in + children at its asset's events -- TWAP, VWAP, slicing, iceberg, participation + -- each child filled by the run's own fill policy and cost model through + `ExecutionPipeline.work_child` and settled on the parent; terms an algorithm + cannot work are refused before an order exists. The result is then an + `AlgorithmicResult` -- the same projection of the run, and the + `AlgorithmBook` beside it: every parent, child and fill (`ReplayResult.algorithms` + for a replay). `BacktestResult` itself still stores only the run. + A paused replay keeps it (`replay_snapshot.restore(..., execution=)`), and + `manifest_for_run(..., execution=)` records the plan's id. +* **A derivative's lifecycle, declared and carried out** [FUT-013]. + `InstrumentRecord.terms` declares `alphalab.instrument.OptionTerms` (call or + put, strike, `AMERICAN` or `EUROPEAN` with its window, `CASH` or `PHYSICAL`, + a `Deliverable` in any quantity per contract, an `AutomaticExercise` + threshold or none) or `FutureTerms` (cash, or delivery of a `Deliverable`). + `alphalab.portfolio.Exercise`, `Assignment` and `OptionExpiry` -- on the run's + schedule as `ScheduledExercise`, `ScheduledAssignment`, `ScheduledExpiry`, or + through `RunEngine.exercise` / `assign` / `expire_option` -- move cash, contracts + and the deliverable through the portfolio's fill authority and each + strategy's share; a physical future delivers at its final price. What cannot + be delivered or paid for is refused before anything moves. `AssetType.INDEX`, + a reference value never traded. `alphalab.runtime.session_close` and + `settles_at` read a lifecycle's instants from a declared market calendar and + settlement rule. +* **The macro engine's series as observations** [FUT-008]. + `alphalab.api.observations_from_indicator`. +* **`LiveSession.deliver_observation`** [OFE-027]. +* **Examples 72 to 75** -- a multi-asset strategy over prices, sentiment and + macro releases, a model fitted before a boundary and traded after it + [FUT-006, FUT-007], one decision executed whole and by four algorithms, + measured as implementation shortfall [FUT-009], and an option's whole life on + a declared market's calendar [FUT-013] -- each held by an integration + test and run from every built artifact by `tests/installed_smoke.py`. + `benchmarks/benchmark_algorithmic_execution.py`. **`docs/COMPOSING_STRATEGIES.md`** + and three audits: `docs/audit/STRATEGY_CAPABILITY_MATRIX.md`, + `docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md`, + `docs/audit/FUTURE_READINESS_V4_1_1.md`. + +## Fixed + +* **`context.market.bar(asset_id)` returned `None` for every asset** [KD-005]. + Since v2.10 `MarketView.bar` looked the bare asset id up in an index the + market engine keys `"_"`. It now returns the asset's latest + bar, the longer interval on a tie. +* **The reinforcement-learning docstrings reported a fixed defect as live** + [DOC-012]. +* **A capability invariant reached one of its statuses by the luck of its seed** + [TST-023]. Every draw now also checks a declaration built to offer what the + flow needs, which must be compatible. + +## Public API changes + +| Name | Change | +| --- | --- | +| `alphalab.api.backtest` | keyword-only `observations`, `schedule` and `execution`, empty or `None` by default: no effect unless given | +| `alphalab.api.replay` | keyword-only `observations`, `schedule` and `execution` | +| `alphalab.backtesting.PausedReplay` | fields `schedule`, empty by default, and `algorithms`, `None` by default | +| `alphalab.backtesting.ReplayResult` | property `algorithms` | +| `alphalab.backtesting.ReplayBacktest.run` | keyword-only `observations`, `schedule` and `execution` | +| `alphalab.backtesting.BacktestEngine.run` | keyword-only `schedule` and `execution` | +| `alphalab.backtesting.replay_snapshot.restore` | `schedule`, empty by default, and `execution`, `None` by default; `ReplaySnapshot.schedule_digest` and `algorithms` | +| `alphalab.runtime.execution_pipeline.ExecutionRouting` | member `ALGORITHMIC`; `ExecutionMode.works_children` | +| `alphalab.core.enums.AssetType` | members `BOND` and `INDEX` | +| `alphalab.instrument.record.InstrumentRecord` | fields `last_trade_at` and `terms`, `None` by default; `InstrumentRegistry.expiring`, a derived index | +| `alphalab.lifecycle.manifest_for_run`, `canonical_manifest_key`, `derive_manifest_id` | keyword or trailing `schedule` / `schedule_digest` (`None`) and `dataset_parts` (empty) by default, and `execution` / `execution_plan_id` (`None`); `ReproducibilityManifest.schedule_digest`, `dataset_parts` and `execution_plan_id` | +| `alphalab.portfolio.engine.PortfolioEngine.apply_fill` | keyword-only `final_settlement`, `False` by default | +| `alphalab.backtesting.schedule` (with `ScheduledExercise`, `ScheduledAssignment`, `ScheduledExpiry` and `RunSchedule.of(exercises=, assignments=, expiries=)`), `alphalab.backtesting.algorithmic` and `alphalab.instrument.terms` and their names; `alphalab.portfolio.Exercise`, `Assignment`, `OptionExpiry`; `RunEngine.exercise`, `assign`, `expire_option`; `ExecutionPipeline.exercise`, `assign`, `expire_option`; `AllocationEngine.shares_of`, `apply_lifecycle`; `alphalab.runtime.session_close`, `settles_at`; `AlgorithmicResult`, `ExecutionPipeline.work_child`, `ChildTurn`; `alphalab.portfolio.ContractSettlement`; `alphalab.api.CombinedDataset`, `CombinedProvenance`, `CombinedSourceBytes`, `combine_datasets`, `combined_market_dataset`, `observations_from_indicator`, `bond_schedule`; `RunEngine.fund`, `RunEngine.settle_contract`, `ExecutionPipeline.settle_contract`, `AllocationEngine.settle_asset`, `LiveSession.deliver_observation`, `held_past_expiry` | added | + +## Accepted as future work + +FUT-011 (a queue model), FUT-014 (a strategy-facing exercise instruction), +FUT-015 (settlement-date accounting), FUT-016 (richer contract terms) and FUT-017 +(margin and forced liquidation), each with its limitation, interim behaviour and +acceptance criteria on `ROADMAP.md`. FUT-009 and FUT-013, accepted by the audit +and the continuation, were delivered by the candidate's last two milestones. + +--- + # [4.1.0] - 2026-10-11 **A replay that pauses and resumes to the identical result, CPython 3.12 to 3.14 diff --git a/README.md b/README.md index 04f4d819..2d10b1a4 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ It exists to answer the questions that decide whether a backtest means anything: | Runtime dependencies | **None** (standard library only) | | Tests | **9,672 passing, 0 skipped, 0 warnings** (each of CPython 3.12, 3.13, 3.14) | | Static typing | **Strict mypy**, repository-wide | -| Examples | **71 / 71** | +| Examples | **75 / 75** | | Release certificate | [11 / 11 checks](docs/audit/RELEASE_CERTIFICATION.md) | | License | MIT | diff --git a/ROADMAP.md b/ROADMAP.md index 54f42079..56c852f1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,6 +159,37 @@ names what the release established and the ADR that records the decision. - **The contract an application builds on, checked against the artifact**: `tests/consumer_contract.py` runs inside every installed wheel and sdist. +## v4.2.0 candidate (the v4.1.1 future-readiness program; release number pending) + +- **One schedule for every timed input** (FUT-004): `alphalab.backtesting.schedule` + -- rates, funding, cash flows, per-unit dividends and coupons, perpetual funding + rates, splits, timers and contract settlements -- applied by `BacktestEngine.run`, + `ReplayBacktest.run` and the facade at each input's instant. +- **External information on every driver** (OFE-027) and **several datasets in + one run** (OFE-028). +- **A paused replay with information outstanding, kept durably** (FUT-003): + replay schema 2. +- **Contract expiry and cash settlement** (FUT-012) and **bonds** (FUT-010): + `InstrumentRecord.last_trade_at`, `ContractSettlement`, `AssetType.BOND`, + `alphalab.api.bond_schedule`. +- **The schedule in a run's manifest** (FUT-005), **the macro engine's series as + observations** (FUT-008), and **features and a fitted model on the execution + path, evidenced** (FUT-006, FUT-007; examples 72 and 73). +- **`context.market.bar` returns the bar** (KD-005; broken since v2.10). +- **Execution algorithms on the historical drivers** (FUT-009): + `alphalab.backtesting.algorithmic.ExecutionPlan` on `BacktestEngine.run`, + `ReplayBacktest.run` and the facade -- each parent held by the OMS and worked in + children at its asset's events, every child filled by the run's own fill policy + and cost model and settled on the parent; TWAP, VWAP, slicing, iceberg and + participation (example 74). +- **A derivative's whole lifecycle, declared** (FUT-013): `InstrumentRecord.terms` + -- `OptionTerms` (American or European, cash or physical, a deliverable in any + quantity, an automatic-exercise rule) and `FutureTerms` (cash or physical + delivery) -- carried out by `Exercise`, `Assignment` and `OptionExpiry` on the + run's schedule through the portfolio's fill authority; `AssetType.INDEX`; + lifecycle instants from a declared market calendar (`session_close`, + `settles_at`); example 75. + # Deliberate boundaries Not built, on purpose. Each has a reason, and most have a regression test that a @@ -522,6 +553,63 @@ holds this list to the ledger: an entry may be left for later only here. row for the identity change; the ledger entry closed. - **Dependencies.** A pipeline schema step, and frozen v4.x payloads. +The entries below were proposed by the v4.1.1 future-readiness audit +(`docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md`); accepting each, and its +horizon, is the maintainer's decision. The candidate itself delivered the rest +of what the audit proposed (section *v4.2.0 candidate*, above). + +## FUT-011 — a queue-position and fill-probability model (target v5.0) + +- **Limitation.** A resting limit order fills by the simulator's rule. +- **Interim behaviour.** Treat passive fills as optimistic in any conclusion. +- **Acceptance criteria.** A declared queue model decides passive fills and is + named in the result's execution assumptions. **Dependencies.** depth data; + BDY-003. + +## FUT-014 — a strategy that instructs an exercise from its hooks (target v4.3) + +- **Limitation.** An exercise is a scheduled input or a `RunEngine` call; no + strategy hook returns one. +- **Interim behaviour.** State the exercise policy as a function over + point-in-time data and schedule its instructions (example 75); the engine + checks each against the contract's window and the book. +- **Acceptance criteria.** A hook's exercise instruction applies at its event's + instant after the instant's records, refused as a scheduled one is, identical + in backtest and replay and across a pause. **Dependencies.** the derivative lifecycle, delivered in the v4.2.0 candidate. + +## FUT-015 — settlement-date accounting (target v5.0) + +- **Limitation.** Cash and positions move at a fill's or a lifecycle event's + instant; the portfolio keeps no unsettled-cash or pending-delivery ledger, + though `settles_at` computes a market's T+n instant. +- **Interim behaviour.** Schedule a lifecycle event at `settles_at`'s instant to + model its lag; treat buying power as optimistic. +- **Acceptance criteria.** Fills and deliveries pend until their declared + settlement date by the market's calendar, buying power excludes unsettled + cash, and every earlier payload reads as settled. **Dependencies.** a + portfolio schema step. + +## FUT-016 — richer contract terms: baskets, cash in lieu, quanto, Bermudan (target v4.3) + +- **Limitation.** One deliverable instrument, the consideration in its currency, + American or European exercise; anything else is refused. A corporate action on + the deliverable does not adjust a contract's terms; the caller re-declares it. +- **Interim behaviour.** Close such a contract before its last trading instant. +- **Acceptance criteria.** A deliverable list with a cash component, a declared + consideration currency with its FX timing, and declared exercise dates each + settle through the portfolio's fill authority, attributed, and round-trip; a + split or merger adjusts the contracts on its deliverable by a declared rule. + **Dependencies.** the derivative lifecycle, delivered in the v4.2.0 candidate. + +## FUT-017 — margin, collateral and forced liquidation (target v5.0) + +- **Limitation.** An assignment or delivery the book cannot cover stops the run + with the shortfall named; nothing calls margin or liquidates. +- **Interim behaviour.** Hold the cash or inventory a delivery needs. +- **Acceptance criteria.** A declared margin model, a margin event on a + shortfall and a declared liquidation policy, recorded and replayable. + **Dependencies.** FUT-015. + --- # Planned before v4 — the former optional list, re-classified (historical) diff --git a/alphalab/allocation/engine.py b/alphalab/allocation/engine.py index ed912872..d1dd127c 100644 --- a/alphalab/allocation/engine.py +++ b/alphalab/allocation/engine.py @@ -526,6 +526,110 @@ def apply_split(state: AllocationState, asset_id: str, ratio: Decimal) -> Alloca restated = restated.set(strategy_id, held.set(asset_id, quantity * ratio)) return state if restated is positions else evolve(state, strategy_positions=restated) + @staticmethod + @in_accounting_context + def settle_asset( + state: AllocationState, asset_id: str + ) -> tuple[AllocationState, tuple[StrategyContribution, ...]]: + """Close every strategy's position in ``asset_id``, settled by the venue (FUT-012). + + A contract's final settlement closes the whole position, so each + strategy's own share of it closes too, and what the share cost is released + from a ceilinged strategy's deployed capital -- a target measured against + a position the venue has closed would ask for it back as a trade. Returns + the state and each strategy's share as it stood, the attribution of the + settlement. Nothing is recorded where nothing was. + """ + + positions = state.strategy_positions + if positions is None: + return state, () + shares: list[StrategyContribution] = [] + restated = positions + capital = state.strategy_capital + for strategy_id, held in positions.items(): + quantity = held.get(asset_id) + if quantity is None or quantity == 0: + continue + shares.append(StrategyContribution(strategy_id, quantity.copy_abs())) + restated = restated.set(strategy_id, held.delete(asset_id)) + if strategy_id in capital: + capital = capital.set( + strategy_id, + capital[strategy_id].fill("", asset_id, quantity, -quantity, Decimal("0")), + ) + if not shares: + return state, () + return evolve(state, strategy_positions=restated, strategy_capital=capital), tuple(shares) + + @staticmethod + def shares_of(state: AllocationState, asset_id: str) -> tuple[tuple[str, Decimal], ...]: + """Each strategy's own signed position in ``asset_id``, in strategy order.""" + + positions = state.strategy_positions + if positions is None: + return () + return tuple( + (strategy_id, held[asset_id]) + for strategy_id, held in positions.items() + if held.get(asset_id, Decimal("0")) != 0 + ) + + @staticmethod + def apply_lifecycle( + state: AllocationState, + asset_id: str, + closed: Mapping[str, Decimal], + delivered_asset: str | None = None, + delivered: Mapping[str, Decimal] | None = None, + unit_value: Decimal = Decimal("0"), + ) -> AllocationState: + """Book an exercise, an assignment or a delivery in each strategy's own positions (FUT-013). + + ``closed`` is each strategy's signed quantity of ``asset_id`` that the + event closed -- its share of a contract exercised, assigned or expired. + ``delivered`` is each strategy's signed quantity of ``delivered_asset`` + it received, at ``unit_value`` per unit in the budget's currency, which a + ceilinged strategy's deployed capital books as a fill would. A target is + then measured against what the strategy holds after the event, as it is + after a fill. + """ + + positions = state.strategy_positions + if positions is None: + return state + capital = state.strategy_capital + restated = positions + ctx = ACCOUNTING_CONTEXT + for strategy_id, quantity in closed.items(): + held_map = restated.get(strategy_id, PersistentMap()) + held = held_map.get(asset_id, Decimal("0")) + after = ctx.subtract(held, quantity) + held_map = held_map.delete(asset_id) if after == 0 else held_map.set(asset_id, after) + restated = restated.set(strategy_id, held_map) + if strategy_id in capital: + capital = capital.set( + strategy_id, + capital[strategy_id].fill("", asset_id, held, -quantity, Decimal("0")), + ) + if delivered_asset is not None and delivered: + for strategy_id, units in delivered.items(): + held_map = restated.get(strategy_id, PersistentMap()) + held = held_map.get(delivered_asset, Decimal("0")) + after = ctx.add(held, units) + held_map = ( + held_map.delete(delivered_asset) + if after == 0 + else held_map.set(delivered_asset, after) + ) + restated = restated.set(strategy_id, held_map) + if strategy_id in capital: + capital = capital.set( + strategy_id, + capital[strategy_id].fill("", delivered_asset, held, units, unit_value), + ) + return evolve(state, strategy_positions=restated, strategy_capital=capital) + @staticmethod def strategy_position(state: AllocationState, strategy_id: str, asset_id: str) -> Decimal: """A strategy's own signed position in an asset; zero when it holds none. diff --git a/alphalab/api.py b/alphalab/api.py index d6158140..ad63d321 100644 --- a/alphalab/api.py +++ b/alphalab/api.py @@ -100,13 +100,15 @@ from __future__ import annotations +import hashlib import math -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Iterable, Mapping, Sequence from dataclasses import dataclass +from datetime import date from decimal import Decimal from enum import Enum, auto from pathlib import Path -from typing import overload +from typing import Any, overload from alphalab.alt_data.exceptions import AltDataInputError from alphalab.alt_data.fundamentals import FiscalPeriod, FundamentalObservation, StatementType @@ -116,18 +118,27 @@ from alphalab.alt_data.provenance import DataProvenance from alphalab.alt_data.sessions import SessionCalendar, place_in_session from alphalab.alt_data.source import ObservationSource +from alphalab.alt_data.streaming import ObservationDelivery from alphalab.analytics.engine import AnalyticsEngine from alphalab.analytics.risk_model import Classification +from alphalab.backtesting.algorithmic import ExecutionPlan from alphalab.backtesting.dataset import MarketDataset from alphalab.backtesting.engine import BacktestEngine from alphalab.backtesting.replay import PausedReplay, ReplayBacktest, ReplayProgress -from alphalab.backtesting.state import BacktestResult, ReplayResult +from alphalab.backtesting.schedule import ( + EMPTY_SCHEDULE, + RunSchedule, + ScheduledDistribution, + ScheduledSettlement, +) +from alphalab.backtesting.state import AlgorithmicResult, BacktestResult, ReplayResult from alphalab.common.exceptions import AlphaLabValidationError from alphalab.common.point_in_time import PointInTimeStamp from alphalab.conventions.lot import LotSpecification from alphalab.conventions.market import MarketConvention from alphalab.conventions.settlement import SettlementRule from alphalab.conventions.tick import TickSchedule +from alphalab.core.enums import AssetType from alphalab.data.assets import EquitySpec, FutureSpec, OptionSpec from alphalab.data.cleaning import CleaningPolicy, clean_records from alphalab.data.corporate_actions import PriceBasis @@ -170,12 +181,16 @@ from alphalab.factor_library.observations import ObservationFrame, observations_from_dataset from alphalab.factor_library.panel import FeaturePanel from alphalab.futures.contract import FutureContract +from alphalab.instrument.record import InstrumentRecord from alphalab.instrument.registry import ( InstrumentRegistry, classification_history, get_instrument, sector_as_of, ) +from alphalab.macro.bond import Bond +from alphalab.macro.bond import cash_flows as macro_cash_flows +from alphalab.macro.indicator import IndicatorMetadata, IndicatorObservation from alphalab.market.normalization import ( NormalizationPolicy, normalize_wire_bar, @@ -184,6 +199,7 @@ ) from alphalab.market.record import MarketInput from alphalab.options.contract import OptionContract +from alphalab.portfolio.corporate_actions import CashFlowKind, ContractSettlement from alphalab.research.engine import ResearchEngine from alphalab.research.exceptions import ResearchValidationError from alphalab.research.signals import SignalDiagnostics, signal_diagnostics @@ -199,6 +215,9 @@ "AvailabilityFromColumn", "AvailabilityNotDeclared", "AvailabilityRule", + "CombinedDataset", + "CombinedProvenance", + "CombinedSourceBytes", "DataRequest", "DataSelection", "EventColumns", @@ -211,7 +230,10 @@ "TimestampReading", "WireTimestamp", "backtest", + "bond_schedule", "clean_dataset", + "combine_datasets", + "combined_market_dataset", "convention_from_spec", "currency_classification", "future_contract_from_spec", @@ -224,6 +246,7 @@ "lift_wire_records", "normalize_records", "observation_source", + "observations_from_indicator", "observe", "option_contract_from_spec", "replay", @@ -504,21 +527,244 @@ def to_market_dataset(dataset: Dataset, policy: NormalizationPolicy) -> MarketDa return MarketDataset.of(version, sorted(inputs, key=lambda item: item.timestamp)) +# --------------------------------------------------------------------------- # +# Several datasets, one run (v4.1.1) +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True, slots=True) +class CombinedSourceBytes: + """How many source bytes the parts of a combined dataset recorded, in all. + + What a reproducibility manifest checks is not zero: a combined dataset of + parts that each recorded their bytes records them all. + """ + + byte_count: int + + +@dataclass(frozen=True, slots=True) +class CombinedProvenance: + """The identity of several canonical datasets read by one run (v4.1.1). + + Derived, never chosen, exactly as a single dataset's version is: from the + parts' own derived versions and content hashes, **in the order given**, + because that order is the order records sharing an instant are read in. + + Attributes: + dataset_version: ``"combined-"`` and the SHA-256 of every part's + version, one per line, in order. + content_hash: The SHA-256 of every part's content hash, in order. + source: Every part's recorded source bytes, counted together. + parts: Each part's ``(dataset_version, content_hash)``, in order -- the + exact datasets a rerun must be given back. + """ + + dataset_version: str + content_hash: str + source: CombinedSourceBytes + parts: tuple[tuple[str, str], ...] + + +@dataclass(frozen=True, slots=True) +class CombinedDataset: + """Several canonical datasets, each with the policy that normalizes it, as one input. + + A strategy that trades an equity and a futures contract, or reads two + providers, reads datasets that are each one series kind from one source -- + :class:`~alphalab.data.ingestion.IngestionRequest` declares one asset class + and one source per dataset -- and until v4.1.1 a run could name only one of + them: :func:`to_market_dataset` takes one, and a hand-merged + :class:`~alphalab.backtesting.dataset.MarketDataset` carried whatever id its + author typed. This keeps every part's provenance and derives the run's + dataset identity from them (:class:`CombinedProvenance`), so + :func:`~alphalab.lifecycle.reproducibility.manifest_for_run` can check a + run against it as it checks a single dataset. + + Build one with :func:`combine_datasets`; read its records with + :func:`combined_market_dataset`. + + Attributes: + parts: ``(dataset, policy)`` pairs, in the order records sharing an + instant are read. + """ + + parts: tuple[tuple[Dataset, NormalizationPolicy], ...] + + def require_provenance(self) -> CombinedProvenance: + """The combined identity, derived from every part's provenance. + + Raises: + DataValidationError: If a part records no provenance. + """ + + identities = tuple( + (provenance.dataset_version, provenance.content_hash) + for provenance in (dataset.require_provenance() for dataset, _ in self.parts) + ) + versions = "\n".join(version for version, _ in identities) + hashes = "\n".join(content for _, content in identities) + return CombinedProvenance( + dataset_version="combined-" + hashlib.sha256(versions.encode("utf-8")).hexdigest(), + content_hash=hashlib.sha256(hashes.encode("utf-8")).hexdigest(), + source=CombinedSourceBytes( + sum(dataset.require_provenance().source.byte_count for dataset, _ in self.parts) + ), + parts=identities, + ) + + @property + def dataset_version(self) -> str: + """The combined dataset's derived version, the id a run over it records.""" + + return self.require_provenance().dataset_version + + +def combine_datasets( + parts: Sequence[tuple[Dataset, NormalizationPolicy]], +) -> CombinedDataset: + """Read several canonical datasets as one run's input, keeping each one's identity. + + Raises: + DataValidationError: If fewer than two parts are given -- one dataset is + :func:`to_market_dataset`'s, with its own version -- if a part + records no provenance, or if one dataset is given twice. + """ + + if len(parts) < 2: + raise DataValidationError( + f"A combined dataset joins two or more datasets; {len(parts)} given. A single " + "dataset is read by to_market_dataset, under its own version." + ) + seen: set[str] = set() + for dataset, _ in parts: + version = dataset.require_provenance().dataset_version + if version in seen: + raise DataValidationError( + f"Dataset {version} is given twice. Reading it twice would publish every " + "one of its records twice." + ) + seen.add(version) + return CombinedDataset(tuple(parts)) + + +def combined_market_dataset(combined: CombinedDataset) -> MarketDataset: + """Every part's records as one ordered stream, under the combined version. + + Records are ordered by timestamp, then by part in the order given, then by + their order within the part -- so the stream is the same on every host and + asynchronous series keep their own instants: nothing is filled, aligned or + carried forward to a common clock. + + Raises: + DataValidationError: If two parts carry the same kind of record for the + same asset at the same instant. Two providers' prices for one + instrument at one instant are two answers to one question, and the + market engine would take whichever came second as current; choose + one source, or clean the overlap out of a part. + """ + + version = combined.require_provenance().dataset_version + keyed: list[tuple[float, int, int, MarketInput]] = [] + owner: dict[tuple[str, str, float], str] = {} + for part, (dataset, policy) in enumerate(combined.parts): + name = dataset.require_provenance().dataset_version + for index, item in enumerate(normalize_records(dataset.records, policy)): + key = (type(item).__name__, item.asset_id, item.timestamp) + if key in owner and owner[key] != name: + raise DataValidationError( + f"Datasets {owner[key]} and {name} both carry a {key[0]} for asset " + f"{item.asset_id} at {item.timestamp!r}. A run reads one price per " + "instrument per instant; choose one source for it." + ) + owner[key] = name + keyed.append((item.timestamp, part, index, item)) + keyed.sort(key=lambda entry: entry[:3]) + return MarketDataset.of(version, [item for *_, item in keyed]) + + +@overload +def backtest( + config: RunConfig, + dataset: Dataset, + strategy_state: StrategyRuntimeState, + context_factory: ContextFactory, + policy: NormalizationPolicy, + *, + observations: Iterable[ObservationDelivery[Any]] = (), + schedule: RunSchedule = EMPTY_SCHEDULE, + execution: None = None, +) -> BacktestResult: ... + + +@overload +def backtest( + config: RunConfig, + dataset: Dataset, + strategy_state: StrategyRuntimeState, + context_factory: ContextFactory, + policy: NormalizationPolicy, + *, + observations: Iterable[ObservationDelivery[Any]] = (), + schedule: RunSchedule = EMPTY_SCHEDULE, + execution: ExecutionPlan, +) -> AlgorithmicResult: ... + + +@overload +def backtest( + config: RunConfig, + dataset: Dataset, + strategy_state: StrategyRuntimeState, + context_factory: ContextFactory, + policy: NormalizationPolicy, + *, + observations: Iterable[ObservationDelivery[Any]] = (), + schedule: RunSchedule = EMPTY_SCHEDULE, + execution: ExecutionPlan | None, +) -> BacktestResult: ... + + def backtest( config: RunConfig, dataset: Dataset, strategy_state: StrategyRuntimeState, context_factory: ContextFactory, policy: NormalizationPolicy, + *, + observations: Iterable[ObservationDelivery[Any]] = (), + schedule: RunSchedule = EMPTY_SCHEDULE, + execution: ExecutionPlan | None = None, ) -> BacktestResult: """Run a backtest over a canonical dataset, carrying its identity into the result. ``result.dataset_id`` is the dataset's derived version, so the run names the exact data it consumed. + + ``observations`` (v4.1.1) -- a + :class:`~alphalab.alt_data.streaming.DeliverySchedule` from + :func:`~alphalab.alt_data.streaming.delivery_schedule`, several chained, or + any deliveries -- reach strategies defining ``on_observation`` at each + record's knowledge instant, as :meth:`BacktestEngine.run + ` delivers them. Until + v4.1.1 this function took none, so an application could not hand external + information to a run without leaving this module. ``schedule`` (v4.1.1) + carries every other timed input -- rates, funding, cash flows, splits, + timers, contract settlements; see :mod:`alphalab.backtesting.schedule`. + ``execution`` (v4.1.1, ledger FUT-009) works every order in children by an + execution algorithm, and the result is then an + :class:`~alphalab.backtesting.AlgorithmicResult` carrying the book; see + :mod:`alphalab.backtesting.algorithmic`. """ return BacktestEngine.run( - config, to_market_dataset(dataset, policy), strategy_state, context_factory + config, + to_market_dataset(dataset, policy), + strategy_state, + context_factory, + observations, + schedule=schedule, + execution=execution, ) @@ -529,6 +775,10 @@ def replay( strategy_state: StrategyRuntimeState, context_factory: ContextFactory, policy: NormalizationPolicy, + *, + observations: Iterable[ObservationDelivery[Any]] = (), + schedule: RunSchedule = EMPTY_SCHEDULE, + execution: ExecutionPlan | None = None, ) -> ReplayResult: ... @@ -541,6 +791,9 @@ def replay( policy: NormalizationPolicy, *, pause_when: Callable[[ReplayProgress], bool], + observations: Iterable[ObservationDelivery[Any]] = (), + schedule: RunSchedule = EMPTY_SCHEDULE, + execution: ExecutionPlan | None = None, ) -> ReplayResult | PausedReplay: ... @@ -552,6 +805,9 @@ def replay( policy: NormalizationPolicy, *, pause_when: Callable[[ReplayProgress], bool] | None = None, + observations: Iterable[ObservationDelivery[Any]] = (), + schedule: RunSchedule = EMPTY_SCHEDULE, + execution: ExecutionPlan | None = None, ) -> ReplayResult | PausedReplay: """Replay a canonical dataset through the execution path, with the same identity. @@ -561,13 +817,35 @@ def replay( restoring takes back ``to_market_dataset(dataset, policy)`` -- and :func:`resume_replay` continues. See :meth:`~alphalab.backtesting.replay.ReplayBacktest.run`. + + ``observations`` and ``schedule`` (v4.1.1) are applied as :func:`backtest` + applies them, so the replay equals the backtest; a paused replay's payload + records the schedule's digest, and restoring it takes the schedule back + (ledger FUT-003). ``execution`` (v4.1.1, ledger FUT-009) works every order + in children, as :func:`backtest` does; restoring a paused replay takes the + plan back. """ market = to_market_dataset(dataset, policy) if pause_when is None: - return ReplayBacktest.run(config, market, strategy_state, context_factory) + return ReplayBacktest.run( + config, + market, + strategy_state, + context_factory, + observations=observations, + schedule=schedule, + execution=execution, + ) return ReplayBacktest.run( - config, market, strategy_state, context_factory, pause_when=pause_when + config, + market, + strategy_state, + context_factory, + pause_when=pause_when, + observations=observations, + schedule=schedule, + execution=execution, ) @@ -1325,6 +1603,136 @@ def _ingest[T: SetRecord]( return PointInTimeIngestion(build_observation_set(name, records, source), tuple(rejected)) +def observations_from_indicator( + metadata: IndicatorMetadata, + releases: Sequence[IndicatorObservation], + source: ObservationSource, + *, + name: str | None = None, +) -> ObservationSet[ExternalObservation]: + """The macro engine's releases of one indicator as a point-in-time set (v4.1.1, FUT-008). + + :mod:`alphalab.macro` keeps a release's reference period and release date + apart, and marks a revision; a strategy reads external information as + :class:`~alphalab.alt_data.observation.ExternalObservation`. This lifts one + into the other without re-ingesting: each release becomes an observation + of ``metadata.indicator_id`` -- lowercased, the observation vocabulary's + spelling -- about ``metadata.country``, in + ``metadata.units``, observed at its reference period and available at its + release date, with the period named by its reference instant and the vintage + numbered by release order -- the first release of a period is revision 0, the + next 1, and so on. The consensus estimate does not travel: a surprise is a + second series, which the caller derives with :func:`alphalab.macro.surprise`. + + Raises: + DataValidationError: If a release belongs to another indicator, if two + releases of one period share a release date, or if the + ``is_revision`` flags contradict the release order -- a period's + first release marked a revision, or a later one not. + AltDataInputError: If the indicator id, lowercased, or the country is + not a label the observation vocabulary accepts. + """ + + by_period: dict[float, list[IndicatorObservation]] = {} + for release in releases: + if release.indicator_id != metadata.indicator_id: + raise DataValidationError( + f"A release of {release.indicator_id!r} is not one of {metadata.indicator_id!r}." + ) + by_period.setdefault(release.reference_period, []).append(release) + records: list[ExternalObservation] = [] + for period, vintages in sorted(by_period.items()): + ordered = sorted(vintages, key=lambda release: release.release_date) + for revision, release in enumerate(ordered): + if revision and release.release_date == ordered[revision - 1].release_date: + raise DataValidationError( + f"{metadata.indicator_id} for {period!r} has two releases at " + f"{release.release_date!r}; which one revises the other is undefined." + ) + if release.is_revision != (revision > 0): + raise DataValidationError( + f"{metadata.indicator_id} for {period!r}: the release at " + f"{release.release_date!r} is release {revision} of its period and is " + f"marked is_revision={release.is_revision}." + ) + records.append( + ExternalObservation( + category="economic", + subject=metadata.country, + metric=metadata.indicator_id.lower(), + value=release.value, + unit=metadata.units, + stamp=PointInTimeStamp.declared(period, release.release_date), + source=source, + period=ReferencePeriod(period, period, f"{metadata.indicator_id}@{period!r}"), + revision=revision, + ) + ) + if not records: + raise DataValidationError(f"No release of {metadata.indicator_id} was given.") + return build_observation_set(name or metadata.indicator_id, records, source) + + +def bond_schedule( + bond: Bond, + instrument: InstrumentRecord, + at: Callable[[date], float], + *, + redemption_price: Decimal, +) -> RunSchedule: + """A bond's coupons and its redemption, as the inputs a run applies (v4.1.1, FUT-010). + + Each coupon of :func:`alphalab.macro.bond.cash_flows` becomes a + :class:`~alphalab.backtesting.schedule.ScheduledDistribution` of + ``bond.coupon_amount`` per unit held -- the bond's face is one unit's face -- + entitling whoever holds at ``at(payment_date)``; the principal is repaid by + a :class:`~alphalab.backtesting.schedule.ScheduledSettlement` at + ``at(maturity)``, closing every position at ``redemption_price`` -- the price + par is quoted at under the instrument's multiplier (``100`` for a bond quoted + per hundred of face whose multiplier is ``face / 100``). ``at`` says what + instant a date's flow is fixed at; nothing here assumes a time of day. + + Accrued interest is not booked separately: a run trading the bond at full + (dirty) prices carries the accrual in the price, and the coupon is then the + cash the accrual became. Trading clean prices would leave the accrual out. + + Raises: + DataValidationError: If ``instrument`` is not a bond, declares no last + trading instant, or stops trading after maturity, or if its currency + is not the bond's. + """ + + if instrument.asset_type is not AssetType.BOND: + raise DataValidationError(f"{instrument.symbol} is a {instrument.asset_type.value}.") + maturity = at(bond.maturity) + if instrument.last_trade_at is None or instrument.last_trade_at > maturity: + raise DataValidationError( + f"{instrument.symbol} must stop trading by its maturity, {maturity!r}; it declares " + f"{instrument.last_trade_at!r}." + ) + if instrument.currency != bond.currency: + raise DataValidationError( + f"{instrument.symbol} trades in {instrument.currency} and the bond pays in " + f"{bond.currency}." + ) + coupons = tuple( + ScheduledDistribution( + instrument.asset_id, + bond.coupon_amount, + bond.currency, + at(flow.payment_date), + kind=CashFlowKind.INTEREST, + reference=f"coupon {flow.payment_date.isoformat()}", + ) + for flow in macro_cash_flows(bond) + if bond.coupon_amount > 0 + ) + redemption = ScheduledSettlement( + ContractSettlement(instrument.asset_id, redemption_price, "redemption"), maturity + ) + return RunSchedule.of(distributions=coupons, settlements=(redemption,)) + + def ingest_observations( rows: Sequence[Mapping[str, object]], name: str, diff --git a/alphalab/backtesting/__init__.py b/alphalab/backtesting/__init__.py index 06b78dd8..680d3272 100644 --- a/alphalab/backtesting/__init__.py +++ b/alphalab/backtesting/__init__.py @@ -25,6 +25,15 @@ ``BacktestStep`` is :class:`~alphalab.runtime.run.RunStep`. See ADR-0030. """ +from alphalab.backtesting.algorithmic import ( + AlgorithmAssignment, + AlgorithmBook, + ChildFill, + ExecutionPlan, + ExecutionPlanError, + FinishedParent, + VenueCheck, +) from alphalab.backtesting.dataset import ( MarketDataset, MarketInput, @@ -51,7 +60,22 @@ ReplayProgress, session_for, ) -from alphalab.backtesting.state import BacktestResult, ReplayResult +from alphalab.backtesting.schedule import ( + EMPTY_SCHEDULE, + Funding, + RunSchedule, + ScheduleCursor, + ScheduledAssignment, + ScheduledCashFlow, + ScheduledDistribution, + ScheduledExercise, + ScheduledExpiry, + ScheduledFundingRate, + ScheduledSettlement, + ScheduledSplit, + ScheduleValidationError, +) +from alphalab.backtesting.state import AlgorithmicResult, BacktestResult, ReplayResult from alphalab.backtesting.views import ( commission_paid, equity_values, @@ -76,13 +100,22 @@ from alphalab.runtime.run import ExecutionMode, RunConfig, RunState, RunStep __all__ = [ + "EMPTY_SCHEDULE", + "AlgorithmAssignment", + "AlgorithmBook", + "AlgorithmicResult", "BacktestEngine", "BacktestError", "BacktestResult", + "ChildFill", "DatasetValidationError", "ExecutionMode", + "ExecutionPlan", + "ExecutionPlanError", "FillDecision", "FillPolicy", + "FinishedParent", + "Funding", "ImmediateFill", "LiquidityCappedFill", "LiquidityContext", @@ -94,12 +127,24 @@ "ReplayProgress", "ReplayResult", "RunConfig", + "RunSchedule", "RunState", "RunStep", + "ScheduleCursor", + "ScheduleValidationError", + "ScheduledAssignment", + "ScheduledCashFlow", + "ScheduledDistribution", + "ScheduledExercise", + "ScheduledExpiry", + "ScheduledFundingRate", + "ScheduledSettlement", + "ScheduledSplit", "StaticFill", "UnpricedAsset", "UnpricedReason", "UnsupportedRecordError", + "VenueCheck", "advance", "commission_paid", "equity_values", diff --git a/alphalab/backtesting/algorithmic.py b/alphalab/backtesting/algorithmic.py new file mode 100644 index 00000000..ba9593e9 --- /dev/null +++ b/alphalab/backtesting/algorithmic.py @@ -0,0 +1,1125 @@ +"""Execution algorithms on the historical drivers: a parent worked in children over market events. + +A strategy decides *what* to own; :mod:`alphalab.execution.algorithms` decides +how a large order is traded -- evenly over a window, with the market's expected +volume, a share of what trades, in slices, a tranche at a time. Until v4.1.1 an +algorithm worked children only on the live path, through +:func:`~alphalab.runtime.broker_routing.route_child_order`; a backtest filled +every order whole, so how a strategy's orders were executed could not be +measured against history (ledger FUT-009). + +An :class:`ExecutionPlan` given to +:meth:`BacktestEngine.run ` or +:meth:`ReplayBacktest.run ` +changes that, and changes nothing else about the run. + +The path +-------- +:: + + strategy intent -> allocation -> risk -> OMS: the parent, accepted and held + -> the algorithm (start_algorithm, release_children) at the asset's events + -> capability check of each child, when the plan declares one + -> ExecutionPipeline.work_child: the run's fill policy and cost model + -> the fill settled on the parent: OMS, portfolio, reservation, attribution + -> record_child_execution / record_child_outcome + -> the parent ended when the algorithm is: filled, expired, cancelled + +The run routes +:attr:`~alphalab.runtime.execution_pipeline.ExecutionRouting.ALGORITHMIC`, so +the pipeline accepts every order and never simulates one whole: risk judges the +whole parent when it is placed, allocation reserves its notional, and the OMS +holds it. Each child is simulated by the same fill policy, simulator and cost +model a whole order would be -- so a liquidity cap applies to each child, and +spread, slippage, impact, commission and fees are charged on each fill -- and +its fill reaches the books through the path every fill takes, so the parent is +one OMS order however many pieces it traded in, and the reservation is consumed +by its fills and released once, when it ends. + +When a child may trade +---------------------- +Everything happens at a market event of the parent's asset, in the order the run +reaches them. At each one, after the run's own step: + +1. a parent placed since the asset's last event is **adopted**: its terms are + the plan's window from the instant it was placed (clipped to its own + expiry), its increment, and its own order type and limit -- a ``MARKET`` + parent's children are market orders, a ``LIMIT`` parent's are limit orders + at its limit. A parent with no assignment, or one the algorithm refuses (a + quantity not a whole number of increments, a VWAP profile that cannot + plan), is **rejected**, never filled whole; +2. children past their slice -- or the window -- **expire**; +3. working children have their **turn** at the event: a child released at an + earlier instant, or -- under the optimistic + :attr:`~alphalab.execution.policy.FillTiming.SAME_EVENT` -- at this one; +4. the algorithm **releases** what its rule says is due at the event's instant, + :class:`~alphalab.execution.algorithms.Participation` sizing from the volume + the asset's events have printed so far (a bar's volume, a trade's quantity; + a quote shows depth, not volume, and counts as nothing observed). Under + ``SAME_EVENT`` a child released now has its turn now; +5. a parent whose algorithm has finished is **ended** through + :meth:`~alphalab.runtime.execution_pipeline.ExecutionPipeline.apply_terminal_outcome` + -- expired, cancelled, or rejected if it never filled -- which retires its + reservation and contributions. + +Nothing reads a later event: a child sees the event it is worked at, and the +algorithm sees the instant and the volume printed by it. A parent the run itself +ends -- a split or a contract settlement cancels working orders -- has its +algorithm cancelled. A parent still working when the data ends stays working, as +any resting order does, and is listed by :attr:`AlgorithmBook.incomplete`; a run +continued with more data continues it. + +What is not done +---------------- +A child's fill reaches its strategy through the portfolio its next context +shows, not through ``on_fill`` -- as a venue fill does on the live path. +:class:`~alphalab.execution.algorithms.Participation` with +:attr:`~alphalab.execution.algorithms.IncompletePolicy.COMPLETE_AT_END` is +refused: it sends a child after the window has closed, and here no child +outlives its window. Each child is judged by the capability declaration, if +the plan states one; risk judged the parent. Paper and live runs route children +to their venue instead. + +Persistence +----------- +An :class:`AlgorithmBook` is a value. :func:`capture_book` writes it as plain +primitives and :func:`book_from_primitives` reads them back, given the plan it +was produced under -- refused, by :attr:`ExecutionPlan.plan_id`, if it is +another -- and every algorithm run is rebuilt by +:func:`~alphalab.execution.algorithms.start_algorithm` and checked against its +recorded ``algorithm_id``, so an altered payload is refused rather than +continued. A paused replay carries its book in its payload. +""" + +from __future__ import annotations + +import hashlib +import math +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field, replace +from decimal import Decimal +from typing import Any, Final +from uuid import UUID + +from alphalab.allocation.engine import AllocationEngine +from alphalab.backtesting.exceptions import BacktestError +from alphalab.common.append_log import AppendOnlyLog +from alphalab.common.arithmetic import canonical_text +from alphalab.common.order_terms import OrderTerms, TimeInForce +from alphalab.common.persistent_map import PersistentMap, PersistentSet +from alphalab.core.capabilities import ( + Capability, + CapabilityDeclaration, + Compatibility, + Support, + check_compatibility, + order_requirements, +) +from alphalab.core.contribution import StrategyContribution +from alphalab.core.enums import OrderStatus, OrderType, Side +from alphalab.core.order_request import OrderRequest +from alphalab.execution.algorithms import ( + TWAP, + VWAP, + AlgorithmState, + AlgorithmStatus, + AlgorithmTerms, + ChildOrder, + ChildProgress, + ExecutionAlgorithm, + Iceberg, + IncompletePolicy, + IntervalVolume, + MissingVolumePolicy, + Participation, + Slicing, + TrancheRandomization, + Urgency, + VolumeProfile, + algorithm_configuration_id, + cancel_algorithm, + record_child_execution, + record_child_outcome, + release_children, + start_algorithm, +) +from alphalab.execution.exceptions import ExecutionValidationError +from alphalab.execution.policy import FillPolicy, FillTiming +from alphalab.market.events import ( + BarClosed, + MarketEvent, + QuoteReceived, + TickReceived, + TradeReceived, +) +from alphalab.oms.ids import OrderId +from alphalab.oms.order import Order as OMSOrder +from alphalab.portfolio.fx import NO_RATES, FxRates +from alphalab.runtime.execution_pipeline import ( + ExecutionPipeline, + ExecutionPipelineState, + ExecutionRouting, +) +from alphalab.runtime.run import ExecutionMode, RunConfig, RunState + +__all__ = [ + "ALGORITHM_BOOK_SCHEMA", + "EXECUTION_PLAN_SCHEME", + "AlgorithmAssignment", + "AlgorithmBook", + "ChildFill", + "ExecutionPlan", + "ExecutionPlanError", + "FinishedParent", + "VenueCheck", + "algorithmic_run", + "book_from_primitives", + "capture_book", + "finish", + "work", +] + +#: Scheme tag of an execution plan's identity. +EXECUTION_PLAN_SCHEME: Final = "alphalab.execution_plan.v1" + +#: The version of the primitives :func:`capture_book` writes. +ALGORITHM_BOOK_SCHEMA: Final = 1 + +_ZERO = Decimal("0") + + +class ExecutionPlanError(BacktestError): + """A plan, or a book written under one, that cannot be used as given.""" + + +def _digest(lines: Iterable[str]) -> str: + return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest() + + +# --------------------------------------------------------------------------- # +# The plan +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True, slots=True) +class AlgorithmAssignment: + """How the parents of one asset -- or of every asset -- are worked. + + Attributes: + algorithm: The configuration, as :mod:`alphalab.execution.algorithms` + states it. + window_seconds: How long each parent is worked, from the instant it was + placed. A parent with an earlier expiry is worked until then. + quantity_increment: The smallest unit a child trades in -- a share, a + contract, a lot. No default: it is a market convention. A parent + that is not a whole number of them is rejected. + + Raises: + ExecutionPlanError: If the window is not a positive finite number of + seconds, the increment is not positive, or the algorithm sends + children after its window (``Participation`` with + ``COMPLETE_AT_END``), which a historical run does not model. + """ + + algorithm: ExecutionAlgorithm + window_seconds: float + quantity_increment: Decimal + + def __post_init__(self) -> None: + window = self.window_seconds + if isinstance(window, bool) or not isinstance(window, int | float): + raise ExecutionPlanError(f"window_seconds must be a number, got {window!r}.") + if not math.isfinite(window) or window <= 0: + raise ExecutionPlanError(f"A window of {window!r} seconds contains no time to trade.") + if not isinstance(self.quantity_increment, Decimal) or not self.quantity_increment > _ZERO: + raise ExecutionPlanError( + f"A quantity increment of {self.quantity_increment!r} is not a unit." + ) + algorithm = self.algorithm + if not isinstance(algorithm, TWAP | VWAP | Participation | Slicing | Iceberg): + raise ExecutionPlanError(f"{algorithm!r} is not an execution algorithm.") + if ( + isinstance(algorithm, Participation) + and algorithm.at_end is IncompletePolicy.COMPLETE_AT_END + ): + raise ExecutionPlanError( + "Participation with COMPLETE_AT_END sends a child after its window closes; a " + "historical run works no child beyond its window. Use LEAVE_UNFILLED, and the " + "quantity not filled is reported unfilled." + ) + + def _render(self) -> list[str]: + return [ + f"algorithm={algorithm_configuration_id(self.algorithm)}", + f"window={self.window_seconds!r}", + f"increment={canonical_text(self.quantity_increment)}", + ] + + +@dataclass(frozen=True, slots=True) +class VenueCheck: + """What every child is checked against before it trades: the venue's declaration. + + Each child is checked as a ``DAY`` order of its own type, side and quantity, + on the asset class and listing exchange its instrument record declares, + short selling derived from the position held before it -- the check + :func:`~alphalab.runtime.broker_routing.route_child_order` makes on the live + path. A child the venue does not declare it can take is rejected, which + stops the algorithm. + + Attributes: + declaration: The venue's declared capabilities. + account_id: The account the children are sent under. + features: Venue-level capabilities the children depend on. + """ + + declaration: CapabilityDeclaration + account_id: str + features: frozenset[Capability] = frozenset() + + def __post_init__(self) -> None: + if not self.account_id.strip(): + raise ExecutionPlanError("A capability check names no account.") + + +@dataclass(frozen=True, slots=True) +class ExecutionPlan: + """Which algorithm works which parents, in a historical run. + + Attributes: + default: The assignment for every asset ``by_asset`` does not name, or + ``None`` -- a parent in an asset with no assignment is rejected. + by_asset: ``(asset_id, assignment)`` pairs, one per asset; held sorted. + capabilities: The venue declaration each child is checked against, or + ``None`` for no check. + + Raises: + ExecutionPlanError: If the plan assigns nothing, or names an asset twice. + """ + + default: AlgorithmAssignment | None = None + by_asset: tuple[tuple[str, AlgorithmAssignment], ...] = () + capabilities: VenueCheck | None = None + _assignments: Mapping[str, AlgorithmAssignment] = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + pairs = tuple(sorted(self.by_asset, key=lambda pair: pair[0])) + assets = [asset for asset, _ in pairs] + if len(set(assets)) != len(assets): + raise ExecutionPlanError(f"An execution plan names {assets} with repeats.") + if self.default is None and not pairs: + raise ExecutionPlanError( + "An execution plan assigns no algorithm, so it would work no parent." + ) + object.__setattr__(self, "by_asset", pairs) + object.__setattr__(self, "_assignments", dict(pairs)) + + def assignment_for(self, asset_id: str) -> AlgorithmAssignment | None: + """The assignment working ``asset_id``'s parents, or ``None``.""" + + return self._assignments.get(asset_id, self.default) + + @property + def plan_id(self) -> str: + """SHA-256 over every assignment and the capability check.""" + + lines = [EXECUTION_PLAN_SCHEME] + if self.default is not None: + lines.extend(f"default.{line}" for line in self.default._render()) + for asset, assignment in self.by_asset: + lines.extend(f"asset.{asset!r}.{line}" for line in assignment._render()) + check = self.capabilities + if check is not None: + lines.append(f"capabilities={check.declaration.declaration_id}") + lines.append(f"account={check.account_id!r}") + lines.append(f"features={sorted(str(f) for f in check.features)}") + return _digest(lines) + + +# --------------------------------------------------------------------------- # +# The book +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True, slots=True) +class ChildFill: + """One child's fill, as the parent's books received it. + + Attributes: + child_id: The child, ``"/"``. + parent_order_id: The parent OMS order the fill settled on. + execution_id: The execution report's id -- the one the parent's OMS, + portfolio and trade record carry. + quantity: What filled. + price: At what price, costs included. + commission: The cash charges the report carried. + timestamp: The event it filled at. + """ + + child_id: str + parent_order_id: str + execution_id: str + quantity: Decimal + price: Decimal + commission: Decimal + timestamp: float + + +@dataclass(frozen=True, slots=True) +class FinishedParent: + """A parent the book no longer works, and why. + + Attributes: + order_id: The parent OMS order. + status: Its final OMS status. + at: When it ended. + reason: Why -- the algorithm's, or the book's own when it never worked it. + algorithm: The algorithm's final state, or ``None`` for a parent rejected + before an algorithm started. + """ + + order_id: str + status: OrderStatus + at: float + reason: str + algorithm: AlgorithmState | None + + +@dataclass(frozen=True, slots=True) +class AlgorithmBook: + """Every parent a run's execution plan has worked, works, or refused. A value. + + Attributes: + plan: The plan. + working: The algorithm working each open parent, by parent order id, in + the order the parents were adopted. + finished: Every parent ended, by parent order id, in the order they ended. + fills: Every child fill, in the order the books received them. + """ + + plan: ExecutionPlan + working: PersistentMap[str, AlgorithmState] = field(default_factory=PersistentMap) + finished: PersistentMap[str, FinishedParent] = field(default_factory=PersistentMap) + fills: AppendOnlyLog[ChildFill] = field(default_factory=AppendOnlyLog) + + @property + def incomplete(self) -> tuple[AlgorithmState, ...]: + """Algorithms still working a parent: what a run that ended now left unexecuted.""" + + return tuple(self.working.values()) + + def algorithm_for(self, parent_order_id: str) -> AlgorithmState | None: + """The algorithm that worked, or works, a parent; ``None`` if none did.""" + + working = self.working.get(parent_order_id) + if working is not None: + return working + finished = self.finished.get(parent_order_id) + return None if finished is None else finished.algorithm + + def fills_of(self, parent_order_id: str) -> tuple[ChildFill, ...]: + """A parent's child fills, in order.""" + + return tuple(f for f in self.fills if f.parent_order_id == parent_order_id) + + +# --------------------------------------------------------------------------- # +# Working the book at a market event +# --------------------------------------------------------------------------- # + + +def _event_asset(event: MarketEvent) -> str | None: + if isinstance(event, BarClosed): + return event.bar.asset_id + if isinstance(event, QuoteReceived): + return event.quote.asset_id + if isinstance(event, TickReceived | TradeReceived): + return event.tick.asset_id + return None + + +def _printed_volume(event: MarketEvent) -> Decimal | None: + """What the event says traded: a bar's volume, a print's quantity; a quote, nothing.""" + + if isinstance(event, BarClosed): + return event.bar.volume + if isinstance(event, TickReceived | TradeReceived): + return event.tick.quantity + return None + + +def _request_of(pipeline: ExecutionPipelineState, parent: OMSOrder) -> OrderRequest: + """The parent as the algorithm reads it: the request the OMS order was placed from.""" + + order_id = str(parent.order_id.value) + return OrderRequest( + order_id=order_id, + strategy_id=parent.strategy_id, + asset_id=parent.asset_id, + side=parent.side, + quantity=parent.quantity, + price=Decimal(parent.metadata["reference_price"]), + timestamp=parent.created_at, + contributions=AllocationEngine.contributions_for(pipeline.allocation, order_id), + terms=parent.terms, + ) + + +def _end( + book: AlgorithmBook, + pipeline: ExecutionPipelineState, + parent: OMSOrder, + status: OrderStatus, + at: float, + reason: str, + algorithm: AlgorithmState | None, +) -> tuple[AlgorithmBook, ExecutionPipelineState]: + """End a parent, once: through the pipeline when it is still open, then in the book.""" + + order_id = str(parent.order_id.value) + final = status + if parent.is_open: + if status is OrderStatus.REJECTED and parent.filled_quantity > _ZERO: + # The OMS does not reject an order that has traded; what is left of + # it is cancelled instead, and the reason says why. + final = OrderStatus.CANCELLED + pipeline = ExecutionPipeline.apply_terminal_outcome(pipeline, parent, final, at, reason) + else: + final = parent.status + return ( + replace( + book, + working=book.working.delete(order_id) if order_id in book.working else book.working, + finished=book.finished.set( + order_id, FinishedParent(order_id, final, at, reason, algorithm) + ), + ), + pipeline, + ) + + +def _adopt( + book: AlgorithmBook, pipeline: ExecutionPipelineState, asset_id: str, at: float +) -> tuple[AlgorithmBook, ExecutionPipelineState]: + """Start an algorithm on every parent placed in ``asset_id`` since the book last looked.""" + + for oms_id in pipeline.oms.working_orders_for(asset_id): + order_id = str(oms_id.value) + if order_id in book.working or order_id in book.finished: + continue + parent = pipeline.oms.orders.find(oms_id) + assignment = book.plan.assignment_for(asset_id) + if assignment is None: + book, pipeline = _end( + book, + pipeline, + parent, + OrderStatus.REJECTED, + at, + f"No execution algorithm is assigned to {asset_id}; a run that works its " + "orders in children does not fill one whole.", + None, + ) + continue + start = parent.created_at + end = start + assignment.window_seconds + if parent.expire_at is not None: + end = min(end, parent.expire_at) + try: + algorithm = start_algorithm( + _request_of(pipeline, parent), + assignment.algorithm, + AlgorithmTerms( + start=start, + end=end, + quantity_increment=assignment.quantity_increment, + order_type=parent.order_type, + limit_price=parent.limit_price, + ), + ) + except ExecutionValidationError as refused: + book, pipeline = _end( + book, + pipeline, + parent, + OrderStatus.REJECTED, + at, + f"The execution algorithm cannot work this parent: {refused}", + None, + ) + continue + book = replace(book, working=book.working.set(order_id, algorithm)) + return book, pipeline + + +def _capability_refusal( + check: VenueCheck | None, pipeline: ExecutionPipelineState, child: ChildOrder +) -> str | None: + if check is None: + return None + registry = pipeline.config.instruments + record = None if registry is None else registry.record_for(child.asset_id) + if record is None: + return ( + f"Child {child.child_id} cannot be checked against the venue's declaration: no " + f"instrument record declares {child.asset_id}'s asset class and listing exchange." + ) + held = pipeline.portfolio.positions.get(child.asset_id) + report = check_compatibility( + order_requirements( + asset_class=record.asset_type, + listing_venue=record.exchange, + account_id=check.account_id, + order_type=child.order_type, + time_in_force=TimeInForce.DAY, + side=child.side, + quantity=child.quantity, + position_before=_ZERO if held is None else held.quantity, + extended_hours=False, + bracket_orders=False, + margin=False, + features=check.features, + ), + check.declaration, + ) + if report.status is Compatibility.COMPATIBLE: + return None + failing = [c.requirement for c in report.checks if c.support is not Support.SUPPORTED] + return ( + f"Child {child.child_id} is {report.status} with the venue's declaration: " + f"{', '.join(failing)}." + ) + + +def _turn( + book: AlgorithmBook, + pipeline: ExecutionPipelineState, + algorithm: AlgorithmState, + child: ChildOrder, + event: MarketEvent, + rates: FxRates, + fill_policy: FillPolicy, + *, + arriving: bool, +) -> tuple[AlgorithmBook, ExecutionPipelineState, AlgorithmState]: + """One child's turn at ``event``: its fill settled on the parent, its outcome booked.""" + + progress = algorithm.children[child.child_id] + turn = ExecutionPipeline.work_child( + pipeline, child, progress.working, event, fill_policy, rates, arriving=arriving + ) + fills = book.fills + for report in turn.reports: + algorithm = record_child_execution( + algorithm, child.child_id, report.execution_id, report.fill_quantity, report.timestamp + ) + fills = fills.append( + ChildFill( + child.child_id, + child.parent_order_id, + report.execution_id, + report.fill_quantity, + report.fill_price, + report.commission, + report.timestamp, + ) + ) + if turn.outcome is not None and turn.outcome is not OrderStatus.FILLED: + algorithm = record_child_outcome(algorithm, child.child_id, turn.outcome, event.timestamp) + return replace(book, fills=fills), turn.state, algorithm + + +def _work_parent( + book: AlgorithmBook, + pipeline: ExecutionPipelineState, + order_id: str, + event: MarketEvent, + rates: FxRates, + fill_policy: FillPolicy, +) -> tuple[AlgorithmBook, ExecutionPipelineState]: + at = event.timestamp + algorithm = book.working[order_id] + parent = pipeline.oms.orders.find(_oms_id(order_id)) + same_event = pipeline.config.fill_timing is FillTiming.SAME_EVENT + + if not parent.is_open: + # The run ended it -- a split, a settlement -- so the algorithm stops. + algorithm = _cancel_working(algorithm, at, f"The run ended the parent: {parent.status}.") + return _end(book, pipeline, parent, parent.status, at, algorithm.reason, algorithm) + + # Children past their slice, or the window, expire before anything trades. + for child in algorithm.working_children: + if at >= algorithm.terms.end or (child.expires_at is not None and at >= child.expires_at): + algorithm = record_child_outcome(algorithm, child.child_id, OrderStatus.EXPIRED, at) + + # Children released before this event -- or at it, under SAME_EVENT -- trade. + for child in algorithm.working_children: + if child.released_at < at or (same_event and child.released_at == at): + book, pipeline, algorithm = _turn( + book, pipeline, algorithm, child, event, rates, fill_policy, arriving=False + ) + + if not algorithm.is_terminal: + volumes: tuple[IntervalVolume, ...] = () + if isinstance(algorithm.algorithm, Participation): + since = ( + algorithm.terms.start + if algorithm.observed_until is None + else algorithm.observed_until + ) + until = min(at, algorithm.terms.end) + if until > since: + volumes = (IntervalVolume(since, until, _printed_volume(event)),) + algorithm, released = release_children(algorithm, at, volumes) + for child in released: + refusal = _capability_refusal(book.plan.capabilities, pipeline, child) + if refusal is not None: + algorithm = record_child_outcome( + algorithm, child.child_id, OrderStatus.REJECTED, at + ) + algorithm = replace(algorithm, reason=refusal) + continue + if same_event: + book, pipeline, algorithm = _turn( + book, pipeline, algorithm, child, event, rates, fill_policy, arriving=True + ) + + if algorithm.is_terminal: + parent = pipeline.oms.orders.find(parent.order_id) + if algorithm.working_children: + algorithm = _cancel_working(algorithm, at, algorithm.reason) + status = { + AlgorithmStatus.COMPLETED: OrderStatus.FILLED, + AlgorithmStatus.EXPIRED: OrderStatus.EXPIRED, + AlgorithmStatus.CANCELLED: OrderStatus.CANCELLED, + AlgorithmStatus.FAILED: OrderStatus.REJECTED, + }[algorithm.status] + return _end(book, pipeline, parent, status, at, algorithm.reason, algorithm) + return replace(book, working=book.working.set(order_id, algorithm)), pipeline + + +def _oms_id(order_id: str) -> OrderId: + """The OMS key of a parent the book names by its text id.""" + + return OrderId(UUID(order_id)) + + +def _cancel_working(algorithm: AlgorithmState, at: float, reason: str) -> AlgorithmState: + """Stop an algorithm, with every child still working booked cancelled.""" + + working = algorithm.working_children + if not algorithm.is_terminal: + algorithm, working = cancel_algorithm(algorithm, at, reason) + for child in working: + algorithm = record_child_outcome(algorithm, child.child_id, OrderStatus.CANCELLED, at) + return algorithm + + +def algorithmic_run( + config: RunConfig, plan: ExecutionPlan | None, mode: ExecutionMode +) -> tuple[RunConfig, AlgorithmBook | None]: + """The configuration a driver runs in ``mode`` under a plan, and the book it starts with. + + A plan routes the run ``ALGORITHMIC``; no plan leaves the routing to the + mode. A configuration that routes ``ALGORITHMIC`` with no plan is refused: every + order would be held, and none worked. + + Raises: + ExecutionPlanError: If the configuration routes ``ALGORITHMIC`` and no + plan is given. + """ + + if plan is None: + if config.pipeline.routing is ExecutionRouting.ALGORITHMIC: + raise ExecutionPlanError( + "The run routes ALGORITHMIC and no execution plan was given, so every order " + "would be held and none worked. Pass execution=ExecutionPlan(...)." + ) + return replace(config, mode=mode), None + pipeline = replace(config.pipeline, routing=ExecutionRouting.ALGORITHMIC) + return replace(config, mode=mode, pipeline=pipeline), AlgorithmBook(plan) + + +def work( + book: AlgorithmBook, + state: RunState, + event: MarketEvent, + rates: FxRates = NO_RATES, +) -> tuple[AlgorithmBook, RunState]: + """Work every parent in ``event``'s asset at ``event``, after the run's own step at it. + + Call it with the event a record became -- the + :attr:`~alphalab.runtime.execution_pipeline.ExecutionPipelineResult.market_event` + of :meth:`~alphalab.runtime.run.RunEngine.advance` -- and not for a record + the run skipped. See the module docstring for what happens, in order. + + Raises: + ExecutionPlanError: If the run does not route ``ALGORITHMIC``. + """ + + pipeline = state.pipeline + if pipeline.config.routing is not ExecutionRouting.ALGORITHMIC: + raise ExecutionPlanError( + f"An execution plan works a run that routes ALGORITHMIC; this one routes " + f"{pipeline.config.routing.name}." + ) + asset_id = _event_asset(event) + if asset_id is None: + return book, state + book, pipeline = _adopt(book, pipeline, asset_id, event.timestamp) + for order_id, algorithm in tuple(book.working.items()): + if algorithm.parent.asset_id != asset_id: + continue + book, pipeline = _work_parent( + book, pipeline, order_id, event, rates, state.config.fill_policy + ) + return book, replace(state, pipeline=pipeline) + + +def finish(book: AlgorithmBook, state: RunState) -> tuple[AlgorithmBook, RunState]: + """Bring the book up to the run's end: what the run did after each asset's last event. + + A parent placed after its asset's last event -- on the data's last slice, by + a timer -- is adopted, and starts no child, since no event follows. A + contract settled after its last record, or a split applied to an asset that + printed nothing after it, cancels working parents with no event of their + asset to notice it at; their algorithms are cancelled. A parent still open + stays working: :attr:`AlgorithmBook.incomplete`. + """ + + pipeline = state.pipeline + at = state.current_timestamp + for asset_id in dict.fromkeys(order.asset_id for order in state.working_orders): + book, pipeline = _adopt(book, pipeline, asset_id, at) + for order_id, algorithm in tuple(book.working.items()): + parent = pipeline.oms.orders.find(_oms_id(order_id)) + if parent.is_open: + continue + algorithm = _cancel_working(algorithm, at, f"The run ended the parent: {parent.status}.") + book, pipeline = _end( + book, pipeline, parent, parent.status, at, algorithm.reason, algorithm + ) + return book, replace(state, pipeline=pipeline) + + +# --------------------------------------------------------------------------- # +# Persistence +# --------------------------------------------------------------------------- # + + +def _decimal(value: Decimal | None) -> str | None: + return None if value is None else str(value) + + +def _read_decimal(value: Any) -> Decimal | None: + return None if value is None else Decimal(value) + + +def _algorithm_out(algorithm: ExecutionAlgorithm) -> dict[str, Any]: + if isinstance(algorithm, TWAP): + return {"kind": "twap", "slices": algorithm.slices, "urgency": str(algorithm.urgency.kappa)} + if isinstance(algorithm, VWAP): + return { + "kind": "vwap", + "source": algorithm.profile.source, + "intervals": [ + [i.start, i.end, _decimal(i.volume)] for i in algorithm.profile.intervals + ], + "missing_volume": algorithm.missing_volume.value, + "urgency": str(algorithm.urgency.kappa), + } + if isinstance(algorithm, Participation): + return { + "kind": "participation", + "rate": str(algorithm.rate), + "min": _decimal(algorithm.min_child_quantity), + "max": _decimal(algorithm.max_child_quantity), + "at_end": algorithm.at_end.value, + } + if isinstance(algorithm, Slicing): + return { + "kind": "slicing", + "slice_quantity": _decimal(algorithm.slice_quantity), + "slice_count": algorithm.slice_count, + } + randomization = algorithm.randomization + return { + "kind": "iceberg", + "display": str(algorithm.display_quantity), + "randomization": None + if randomization is None + else {"spread": str(randomization.spread), "seed": randomization.seed}, + } + + +def _algorithm_in(payload: Mapping[str, Any]) -> ExecutionAlgorithm: + kind = payload["kind"] + if kind == "twap": + return TWAP(int(payload["slices"]), Urgency(Decimal(payload["urgency"]))) + if kind == "vwap": + return VWAP( + VolumeProfile( + tuple( + IntervalVolume(float(s), float(e), _read_decimal(v)) + for s, e, v in payload["intervals"] + ), + payload["source"], + ), + MissingVolumePolicy(payload["missing_volume"]), + Urgency(Decimal(payload["urgency"])), + ) + if kind == "participation": + return Participation( + Decimal(payload["rate"]), + _read_decimal(payload["min"]), + _read_decimal(payload["max"]), + IncompletePolicy(payload["at_end"]), + ) + if kind == "slicing": + count = payload["slice_count"] + return Slicing( + _read_decimal(payload["slice_quantity"]), None if count is None else int(count) + ) + if kind == "iceberg": + drawn = payload["randomization"] + return Iceberg( + Decimal(payload["display"]), + None + if drawn is None + else TrancheRandomization(Decimal(drawn["spread"]), int(drawn["seed"])), + ) + raise ExecutionPlanError(f"{kind!r} is not an execution algorithm this book can read.") + + +def _terms_out(terms: OrderTerms) -> dict[str, Any]: + return { + "order_type": terms.order_type.name, + "limit_price": _decimal(terms.limit_price), + "stop_price": _decimal(terms.stop_price), + "time_in_force": terms.time_in_force.name, + "expire_at": terms.expire_at, + } + + +def _terms_in(payload: Mapping[str, Any]) -> OrderTerms: + return OrderTerms( + order_type=OrderType[payload["order_type"]], + limit_price=_read_decimal(payload["limit_price"]), + stop_price=_read_decimal(payload["stop_price"]), + time_in_force=TimeInForce[payload["time_in_force"]], + expire_at=None if payload["expire_at"] is None else float(payload["expire_at"]), + ) + + +def _request_out(request: OrderRequest) -> dict[str, Any]: + return { + "order_id": request.order_id, + "strategy_id": request.strategy_id, + "asset_id": request.asset_id, + "side": request.side.name, + "quantity": str(request.quantity), + "price": str(request.price), + "timestamp": request.timestamp, + "contributions": [[c.strategy_id, str(c.quantity)] for c in request.contributions], + "terms": _terms_out(request.terms), + } + + +def _request_in(payload: Mapping[str, Any]) -> OrderRequest: + return OrderRequest( + order_id=payload["order_id"], + strategy_id=payload["strategy_id"], + asset_id=payload["asset_id"], + side=Side[payload["side"]], + quantity=Decimal(payload["quantity"]), + price=Decimal(payload["price"]), + timestamp=float(payload["timestamp"]), + contributions=tuple( + StrategyContribution(sid, Decimal(quantity)) + for sid, quantity in payload["contributions"] + ), + terms=_terms_in(payload["terms"]), + ) + + +def _state_out(state: AlgorithmState) -> dict[str, Any]: + terms = state.terms + return { + "parent": _request_out(state.parent), + "algorithm": _algorithm_out(state.algorithm), + "terms": { + "start": terms.start, + "end": terms.end, + "increment": str(terms.quantity_increment), + "order_type": terms.order_type.name, + "limit_price": _decimal(terms.limit_price), + }, + "algorithm_id": state.algorithm_id, + "children": [ + { + "sequence": p.child.sequence, + "quantity": str(p.child.quantity), + "released_at": p.child.released_at, + "expires_at": p.child.expires_at, + "filled": str(p.filled), + "outcome": None if p.outcome is None else p.outcome.name, + } + for p in state.children.values() + ], + "released": str(state.released), + "filled": str(state.filled), + "returned": str(state.returned), + "executions": list(state.executions), + "observed_volume": str(state.observed_volume), + "observed_until": state.observed_until, + "status": state.status.value, + "reason": state.reason, + "notes": list(state.notes), + } + + +def _state_in(payload: Mapping[str, Any]) -> AlgorithmState: + terms = payload["terms"] + started = start_algorithm( + _request_in(payload["parent"]), + _algorithm_in(payload["algorithm"]), + AlgorithmTerms( + start=float(terms["start"]), + end=float(terms["end"]), + quantity_increment=Decimal(terms["increment"]), + order_type=OrderType[terms["order_type"]], + limit_price=_read_decimal(terms["limit_price"]), + ), + ) + if started.algorithm_id != payload["algorithm_id"]: + raise ExecutionPlanError( + f"An algorithm run recorded as {payload['algorithm_id']} rebuilds as " + f"{started.algorithm_id}: the payload was altered, and continuing it would work " + "another order than the one recorded." + ) + children: PersistentMap[str, ChildProgress] = PersistentMap() + for entry in payload["children"]: + sequence = int(entry["sequence"]) + child = ChildOrder( + child_id=f"{started.parent.order_id}/{sequence}", + parent_order_id=started.parent.order_id, + sequence=sequence, + asset_id=started.parent.asset_id, + side=started.parent.side, + quantity=Decimal(entry["quantity"]), + order_type=started.terms.order_type, + limit_price=started.terms.limit_price, + released_at=float(entry["released_at"]), + expires_at=None if entry["expires_at"] is None else float(entry["expires_at"]), + strategy_id=started.parent.strategy_id, + contributions=started.parent.contributions, + algorithm_id=started.algorithm_id, + ) + outcome = entry["outcome"] + children = children.set( + child.child_id, + ChildProgress( + child, Decimal(entry["filled"]), None if outcome is None else OrderStatus[outcome] + ), + ) + executions: PersistentSet[str] = PersistentSet() + for execution_id in payload["executions"]: + executions = executions.add(execution_id) + until = payload["observed_until"] + return replace( + started, + children=children, + released=Decimal(payload["released"]), + filled=Decimal(payload["filled"]), + returned=Decimal(payload["returned"]), + executions=executions, + observed_volume=Decimal(payload["observed_volume"]), + observed_until=None if until is None else float(until), + status=AlgorithmStatus(payload["status"]), + reason=payload["reason"], + notes=tuple(payload["notes"]), + ) + + +def capture_book(book: AlgorithmBook) -> dict[str, Any]: + """The book as plain primitives, for a payload. Read back by :func:`book_from_primitives`.""" + + return { + "schema_version": ALGORITHM_BOOK_SCHEMA, + "plan_id": book.plan.plan_id, + "working": [_state_out(state) for state in book.working.values()], + "finished": [ + { + "order_id": f.order_id, + "status": f.status.name, + "at": f.at, + "reason": f.reason, + "algorithm": None if f.algorithm is None else _state_out(f.algorithm), + } + for f in book.finished.values() + ], + "fills": [ + [ + f.child_id, + f.parent_order_id, + f.execution_id, + str(f.quantity), + str(f.price), + str(f.commission), + f.timestamp, + ] + for f in book.fills + ], + } + + +def book_from_primitives(payload: Mapping[str, Any], plan: ExecutionPlan) -> AlgorithmBook: + """Read a book :func:`capture_book` wrote, under the plan it was produced by. + + Raises: + ExecutionPlanError: If the schema version is not this build's, ``plan`` + is not the plan the book was produced under, or an algorithm run + does not rebuild to its recorded identity. + """ + + version = payload.get("schema_version") + if version != ALGORITHM_BOOK_SCHEMA: + raise ExecutionPlanError( + f"An algorithm book of schema {version!r}; this build reads {ALGORITHM_BOOK_SCHEMA}." + ) + if payload["plan_id"] != plan.plan_id: + raise ExecutionPlanError( + f"The book was produced under plan {payload['plan_id']} and plan {plan.plan_id} " + "was supplied; continuing it would work its parents another way." + ) + working: PersistentMap[str, AlgorithmState] = PersistentMap() + for entry in payload["working"]: + state = _state_in(entry) + working = working.set(state.parent.order_id, state) + finished: PersistentMap[str, FinishedParent] = PersistentMap() + for entry in payload["finished"]: + recorded = entry["algorithm"] + finished = finished.set( + entry["order_id"], + FinishedParent( + entry["order_id"], + OrderStatus[entry["status"]], + float(entry["at"]), + entry["reason"], + None if recorded is None else _state_in(recorded), + ), + ) + fills: AppendOnlyLog[ChildFill] = AppendOnlyLog() + for child_id, parent_id, execution_id, quantity, price, commission, at in payload["fills"]: + fills = fills.append( + ChildFill( + child_id, + parent_id, + execution_id, + Decimal(quantity), + Decimal(price), + Decimal(commission), + float(at), + ) + ) + return AlgorithmBook(plan, working, finished, fills) diff --git a/alphalab/backtesting/engine.py b/alphalab/backtesting/engine.py index ff89be4a..ff7ff87c 100644 --- a/alphalab/backtesting/engine.py +++ b/alphalab/backtesting/engine.py @@ -31,14 +31,17 @@ from __future__ import annotations -from collections import deque from collections.abc import Iterable from contextlib import AbstractContextManager -from typing import Any +from typing import Any, overload from alphalab.alt_data.streaming import ObservationDelivery +from alphalab.backtesting.algorithmic import ExecutionPlan, algorithmic_run +from alphalab.backtesting.algorithmic import finish as finish_children +from alphalab.backtesting.algorithmic import work as work_children from alphalab.backtesting.dataset import MarketDataset, MarketRecord -from alphalab.backtesting.state import BacktestResult +from alphalab.backtesting.schedule import EMPTY_SCHEDULE, RunSchedule, ScheduleCursor +from alphalab.backtesting.state import AlgorithmicResult, BacktestResult from alphalab.common.evolve import evolve from alphalab.common.ids import id_scope, id_source from alphalab.market.state import MarketState @@ -176,6 +179,7 @@ def finalize(state: RunState) -> BacktestResult: return finalize(state) + @overload @staticmethod def run( config: RunConfig, @@ -183,6 +187,47 @@ def run( strategy_state: StrategyRuntimeState, context_factory: ContextFactory, observations: Iterable[ObservationDelivery[Any]] = (), + *, + schedule: RunSchedule = EMPTY_SCHEDULE, + execution: None = None, + ) -> BacktestResult: ... + + @overload + @staticmethod + def run( + config: RunConfig, + dataset: MarketDataset, + strategy_state: StrategyRuntimeState, + context_factory: ContextFactory, + observations: Iterable[ObservationDelivery[Any]] = (), + *, + schedule: RunSchedule = EMPTY_SCHEDULE, + execution: ExecutionPlan, + ) -> AlgorithmicResult: ... + + @overload + @staticmethod + def run( + config: RunConfig, + dataset: MarketDataset, + strategy_state: StrategyRuntimeState, + context_factory: ContextFactory, + observations: Iterable[ObservationDelivery[Any]] = (), + *, + schedule: RunSchedule = EMPTY_SCHEDULE, + execution: ExecutionPlan | None, + ) -> BacktestResult: ... + + @staticmethod + def run( + config: RunConfig, + dataset: MarketDataset, + strategy_state: StrategyRuntimeState, + context_factory: ContextFactory, + observations: Iterable[ObservationDelivery[Any]] = (), + *, + schedule: RunSchedule = EMPTY_SCHEDULE, + execution: ExecutionPlan | None = None, ) -> BacktestResult: """Run ``dataset`` end to end and return the finished result. @@ -198,25 +243,45 @@ def run( does with information known at ``t`` rests until a price printed after the instant's own -- and an ``on_slice`` at ``t`` sees it. One known after the last record is beyond the data, and is not delivered. + + ``schedule`` (v4.1.1, ledger FUT-004) carries every other timed input -- + exchange rates, funding, cash flows, splits, timers, contract settlements + -- applied by :class:`~alphalab.backtesting.schedule.ScheduleCursor` at + its instant, the rate table it builds converting every step; the + ``observations`` given here are added to it. + + ``execution`` (v4.1.1, ledger FUT-009) works every order in children: + the run routes + :attr:`~alphalab.runtime.execution_pipeline.ExecutionRouting.ALGORITHMIC`, + and the plan's algorithms release, trade and finish each parent at its + asset's events, after the run's own step at each -- see + :mod:`alphalab.backtesting.algorithmic`. The result is then an + :class:`~alphalab.backtesting.state.AlgorithmicResult`, carrying the book. """ - pending = deque(sorted(observations, key=lambda delivery: delivery.order_key)) + config, book = algorithmic_run(config, execution, ExecutionMode.BACKTEST) + cursor = ScheduleCursor.start(schedule.with_observations(observations)) with id_scope(config.seed): state = evolve( initialize(evolve(config, mode=ExecutionMode.BACKTEST), strategy_state), source_id=dataset.dataset_id, ) for record in dataset.records: - # What became knowable before this record's instant, in order; - # an instant's slice closes before anything known after it. - while pending and pending[0].known_at < record.timestamp: - delivery = pending.popleft() - state = close_slice(state, context_factory, before=delivery.known_at) - state = RunEngine.deliver_observation(state, delivery, context_factory) + # Everything ordered before this record's instant, in order; an + # instant's slice closes before anything later than it. + cursor, state = cursor.before(state, record.timestamp, context_factory) # The first record of an instant completes the one before it. - state = close_slice(state, context_factory, before=record.timestamp) - state, _ = advance(state, record, context_factory) - last = state.last_record_timestamp - while pending and last is not None and pending[0].known_at <= last: - state = RunEngine.deliver_observation(state, pending.popleft(), context_factory) - return finalize(close_slice(state, context_factory)) + state = RunEngine.close_slice( + state, context_factory, cursor.rates, before=record.timestamp + ) + state, result = RunEngine.advance( + state, record, context_factory, rates=cursor.rates + ) + if book is not None and result is not None: + book, state = work_children(book, state, result.market_event, cursor.rates) + cursor, state = cursor.finish(state, context_factory, dataset.records[-1].timestamp) + state = RunEngine.close_slice(state, context_factory, cursor.rates) + if book is None: + return finalize(state) + book, state = finish_children(book, state) + return AlgorithmicResult(run=RunEngine.finalize(state), algorithms=book) diff --git a/alphalab/backtesting/replay.py b/alphalab/backtesting/replay.py index 9ca63ccf..9ff2b649 100644 --- a/alphalab/backtesting/replay.py +++ b/alphalab/backtesting/replay.py @@ -72,25 +72,44 @@ :class:`~alphalab.runtime.run.RunState` and :data:`~alphalab.runtime.run_snapshot.RUN_SNAPSHOT_SCHEMA`, both of which are unchanged: the replay envelope nests the run's. + +External information and every other timed input (v4.1.1) +--------------------------------------------------------- +Until v4.1.1 a replay delivered no observations: :meth:`BacktestEngine.run +` merged a +:class:`~alphalab.alt_data.streaming.DeliverySchedule` with its records (ledger +OFE-009) and :meth:`ReplayBacktest.run` took none, so a strategy acting on +news, a statement or a macro release produced one result as a backtest and +another as a replay of the same records. A replay now takes ``observations`` +and a :class:`~alphalab.backtesting.schedule.RunSchedule` -- rates, funding, +cash flows, splits, timers, contract settlements -- applied by the same +:class:`~alphalab.backtesting.schedule.ScheduleCursor` the backtest uses, so the +two apply every input at the same step. A :class:`PausedReplay` carries the +schedule; what it has applied is a function of the last record it took, so a +resume -- in this process or, through :mod:`alphalab.backtesting.replay_snapshot`'s +schema 2, another -- continues with exactly what remained (ledger FUT-003). """ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Iterable from dataclasses import dataclass, replace -from typing import overload +from typing import Any, overload +from alphalab.alt_data.streaming import ObservationDelivery +from alphalab.backtesting.algorithmic import AlgorithmBook, ExecutionPlan, algorithmic_run +from alphalab.backtesting.algorithmic import finish as finish_children +from alphalab.backtesting.algorithmic import work as work_children from alphalab.backtesting.dataset import MarketDataset, MarketRecord from alphalab.backtesting.engine import ( - advance, - close_slice, finalize, id_scope, id_source, initialize, ) from alphalab.backtesting.exceptions import UnsupportedRecordError -from alphalab.backtesting.state import ReplayResult +from alphalab.backtesting.schedule import EMPTY_SCHEDULE, RunSchedule, ScheduleCursor +from alphalab.backtesting.state import AlgorithmicResult, BacktestResult, ReplayResult from alphalab.common.ids import IdStreamPosition, current_id_position, use_id_source from alphalab.replay.engine import ReplayEngine from alphalab.replay.exceptions import InvalidReplayStateError @@ -183,6 +202,14 @@ class PausedReplay: cursor_position: Where the cursor's own identifier stream had reached (seed ``seed + REPLAY_CURSOR_SEED_OFFSET``), or an unseeded position for an unseeded run. + schedule: Every timed input of the replay -- observations, rates, + funding, cash flows, splits, timers, settlements (v4.1.1). What of it + has been applied follows from the last record the cursor took + (:meth:`~alphalab.backtesting.schedule.ScheduleCursor.start`), so it + is held whole rather than as a remainder. + algorithms: The execution plan's book, for a replay given one (v4.1.1, + ledger FUT-009): every parent's algorithm as it stood at the pause, + the children still working included; ``None`` otherwise. Raises: InvalidReplayStateError: If the three do not describe one replay paused @@ -194,6 +221,8 @@ class PausedReplay: run: RunState cursor: ReplayState cursor_position: IdStreamPosition + schedule: RunSchedule = EMPTY_SCHEDULE + algorithms: AlgorithmBook | None = None def __post_init__(self) -> None: cursor = self.cursor @@ -263,6 +292,10 @@ def run( dataset: MarketDataset, strategy_state: StrategyRuntimeState, context_factory: ContextFactory, + *, + observations: Iterable[ObservationDelivery[Any]] = (), + schedule: RunSchedule = EMPTY_SCHEDULE, + execution: ExecutionPlan | None = None, ) -> ReplayResult: ... @overload @@ -274,6 +307,9 @@ def run( context_factory: ContextFactory, *, pause_when: Callable[[ReplayProgress], bool], + observations: Iterable[ObservationDelivery[Any]] = (), + schedule: RunSchedule = EMPTY_SCHEDULE, + execution: ExecutionPlan | None = None, ) -> ReplayResult | PausedReplay: ... @staticmethod @@ -284,6 +320,9 @@ def run( context_factory: ContextFactory, *, pause_when: Callable[[ReplayProgress], bool] | None = None, + observations: Iterable[ObservationDelivery[Any]] = (), + schedule: RunSchedule = EMPTY_SCHEDULE, + execution: ExecutionPlan | None = None, ) -> ReplayResult | PausedReplay: """Replay ``dataset`` through the execution path, to its end or to a pause. @@ -299,8 +338,21 @@ def run( of the replay's streams; an exception it raises propagates, and the replay is not paused. Without it, or once the last record has been taken, the replay finishes and returns its :class:`ReplayResult`. + + ``observations`` and ``schedule`` (v4.1.1) are applied exactly as + :meth:`BacktestEngine.run ` + applies them -- each input at its instant, by + :class:`~alphalab.backtesting.schedule.ScheduleCursor` -- so a replay of + a strategy that acts on external information, settles a second + currency, reacts to timers or holds a contract to its settlement equals + the backtest of it. + + ``execution`` (v4.1.1, ledger FUT-009) works every order in children, as + :meth:`BacktestEngine.run ` + does; a pause keeps the book, and a resume continues every algorithm. """ + config, book = algorithmic_run(config, execution, ExecutionMode.REPLAY) cursor_ids = id_source(cursor_seed(config.seed)) with use_id_source(cursor_ids): @@ -311,7 +363,16 @@ def run( initialize(replace(config, mode=ExecutionMode.REPLAY), strategy_state), source_id=dataset.dataset_id, ) - return _drive(state, replay, cursor_ids, context_factory, pause_when, resumed=False) + return _drive( + state, + replay, + cursor_ids, + context_factory, + pause_when, + schedule.with_observations(observations), + book, + resumed=False, + ) @overload @staticmethod @@ -351,14 +412,24 @@ def resume( A paused replay from :func:`~alphalab.backtesting.replay_snapshot.restore` has been checked against the dataset and the live objects it is given; - this does not check them again. + this does not check them again. Its schedule continues from the last + record it took (:meth:`~alphalab.backtesting.schedule.ScheduleCursor.start`). """ cursor_ids = RunEngine.id_source_at(paused.cursor_position) with RunEngine.resume(paused.run): with use_id_source(cursor_ids): replay = ReplayEngine.resume(paused.cursor, float(paused.cursor.current_index)) - return _drive(paused.run, replay, cursor_ids, context_factory, pause_when, resumed=True) + return _drive( + paused.run, + replay, + cursor_ids, + context_factory, + pause_when, + paused.schedule, + paused.algorithms, + resumed=True, + ) def _drive( @@ -367,6 +438,8 @@ def _drive( cursor_ids: Callable[[], str] | None, context_factory: ContextFactory, pause_when: Callable[[ReplayProgress], bool] | None, + schedule: RunSchedule, + book: AlgorithmBook | None, *, resumed: bool, ) -> ReplayResult | PausedReplay: @@ -374,6 +447,10 @@ def _drive( Called inside the run's identifier scope. The cursor's stream is installed around each cursor transition only, so the two streams never mix. + ``schedule`` is applied by the backtest's own cursor, record by record, + started where the last record the cursor took leaves it; ``book`` works + every parent at its asset's events, after the run's step at each, as the + backtest does. """ total = len(replay.events) @@ -387,6 +464,9 @@ def _drive( f"Replay holds {type(previous).__name__}, not a MarketRecord" ) last = previous + # What of the schedule the replay has applied follows from the last record + # it took, processed or skipped -- see alphalab.backtesting.schedule. + inputs = ScheduleCursor.start(schedule, None if last is None else last.timestamp) ask = not resumed while True: @@ -405,7 +485,7 @@ def _drive( with use_id_source(cursor_ids): paused = ReplayEngine.pause(replay, float(replayed)) position = current_id_position() - return PausedReplay(state, paused, position) + return PausedReplay(state, paused, position, schedule, book) ask = True with use_id_source(cursor_ids): @@ -422,8 +502,11 @@ def _drive( raise UnsupportedRecordError( f"Replay yielded {type(record).__name__}, not a MarketRecord" ) - state = close_slice(state, context_factory, before=record.timestamp) - state, _ = advance(state, record, context_factory) + inputs, state = inputs.before(state, record.timestamp, context_factory) + state = RunEngine.close_slice(state, context_factory, inputs.rates, before=record.timestamp) + state, result = RunEngine.advance(state, record, context_factory, rates=inputs.rates) + if book is not None and result is not None: + book, state = work_children(book, state, result.market_event, inputs.rates) # The cursor's lifecycle log is an audit trail like any subsystem's, and # a declared retention bounds it as it bounds theirs (v4.1): until then # a replay kept one ReplayAdvanced per record whatever the run declared. @@ -432,10 +515,17 @@ def _drive( replay = replace(replay, system_events=kept) last = record replayed += 1 - state = close_slice(state, context_factory) + inputs, state = inputs.finish(state, context_factory, None if last is None else last.timestamp) + state = RunEngine.close_slice(state, context_factory, inputs.rates) + backtest: BacktestResult + if book is None: + backtest = finalize(state) + else: + book, state = finish_children(book, state) + backtest = AlgorithmicResult(run=RunEngine.finalize(state), algorithms=book) return ReplayResult( - backtest=finalize(state), + backtest=backtest, replay_status=replay.status.name, records_replayed=replayed, last_record=last, diff --git a/alphalab/backtesting/replay_snapshot.py b/alphalab/backtesting/replay_snapshot.py index cef597c0..60d413bf 100644 --- a/alphalab/backtesting/replay_snapshot.py +++ b/alphalab/backtesting/replay_snapshot.py @@ -47,8 +47,15 @@ from dataclasses import dataclass from typing import Any, Final +from alphalab.backtesting.algorithmic import ( + ExecutionPlan, + ExecutionPlanError, + book_from_primitives, + capture_book, +) from alphalab.backtesting.dataset import MarketDataset from alphalab.backtesting.replay import PausedReplay, cursor_seed +from alphalab.backtesting.schedule import EMPTY_SCHEDULE, RunSchedule from alphalab.common.append_log import AppendOnlyLog from alphalab.common.constants import DEFAULT_ENCODING from alphalab.common.ids import IdStreamPosition @@ -64,7 +71,7 @@ ) from alphalab.persistence.exceptions import StateDecodeError from alphalab.persistence.serializer import serialize -from alphalab.persistence.upgrade import SchemaHistory +from alphalab.persistence.upgrade import SchemaHistory, SchemaStep from alphalab.replay.events import ( ReplayAdvanced, ReplayPaused, @@ -95,15 +102,34 @@ "restore", ] -#: Schema version this module reads and writes. New in v4.1, so it reads nothing -#: older; a module-local literal for the reason ``RUN_SNAPSHOT_SCHEMA`` is one. -REPLAY_SNAPSHOT_SCHEMA: Final = 1 +#: Schema version this module reads and writes; a module-local literal for the +#: reason ``RUN_SNAPSHOT_SCHEMA`` is one. Version 1 was v4.1's; version 2 +#: (v4.1.1, ledger FUT-003) records the digest of the replay's schedule and, for +#: a replay given an execution plan (FUT-009), its algorithm book. +REPLAY_SNAPSHOT_SCHEMA: Final = 2 _SUBSYSTEM: Final = "replay" -#: How every replay payload a release has written is read by this one. Version 1 -#: is the first, so there is nothing yet to upgrade. -REPLAY_SCHEMA_HISTORY = SchemaHistory(_SUBSYSTEM, REPLAY_SNAPSHOT_SCHEMA) + +def _v1_to_v2(payload: dict[str, Any]) -> dict[str, Any]: + """A version-1 replay was given no schedule and no plan: v4.1 replays took neither.""" + + return {**payload, "schedule_digest": None, "algorithms": None} + + +#: How every replay payload a release has written is read by this one. +REPLAY_SCHEMA_HISTORY = SchemaHistory( + _SUBSYSTEM, + REPLAY_SNAPSHOT_SCHEMA, + ( + SchemaStep( + 1, + "version 2 records the digest of the replay's schedule and its algorithm book; " + "a v4.1 replay had neither", + upgrade=_v1_to_v2, + ), + ), +) @dataclass(frozen=True, slots=True) @@ -140,6 +166,15 @@ class ReplaySnapshot: next_event_id: The record due next. events_dropped: How many earlier lifecycle events a declared retention dropped (``RetentionPolicy.audit_events``). + schedule_digest: :attr:`~alphalab.backtesting.schedule.RunSchedule.digest` + of the replay's schedule, or ``None`` for a replay given none + (v4.1.1, schema 2). The schedule is supplied back to + :func:`restore`, as the dataset is, and held to it. + algorithms: The replay's + :class:`~alphalab.backtesting.algorithmic.AlgorithmBook` as + :func:`~alphalab.backtesting.algorithmic.capture_book` writes it, or + ``None`` for a replay given no execution plan (v4.1.1, schema 2). The + plan is supplied back to :func:`restore` and held to the book. """ run: RunSnapshot @@ -156,6 +191,8 @@ class ReplaySnapshot: last_event_id: str | None next_event_id: str events_dropped: int = 0 + schedule_digest: str | None = None + algorithms: Mapping[str, Any] | None = None schema_version: int = REPLAY_SNAPSHOT_SCHEMA @@ -189,7 +226,8 @@ def capture(paused: PausedReplay) -> ReplaySnapshot: Pure: nothing is minted and nothing is mutated, so capturing one paused replay twice gives equal snapshots. Linear in the run's recorded history - and the dataset's length, as the run's own capture is. + and the dataset's length, as the run's own capture is -- and, since + v4.1.1, in its schedule's length, whose digest it records. """ cursor = paused.cursor @@ -212,6 +250,8 @@ def capture(paused: PausedReplay) -> ReplaySnapshot: last_event_id=records[index - 1].event_id if index else None, next_event_id=records[index].event_id, events_dropped=cursor.system_events.dropped, + schedule_digest=paused.schedule.digest if paused.schedule else None, + algorithms=None if paused.algorithms is None else capture_book(paused.algorithms), ) @@ -330,12 +370,24 @@ def _check_cursor(snapshot: ReplaySnapshot) -> None: ) -def restore(snapshot: ReplaySnapshot, objects: RunObjects, dataset: MarketDataset) -> PausedReplay: +def restore( + snapshot: ReplaySnapshot, + objects: RunObjects, + dataset: MarketDataset, + schedule: RunSchedule = EMPTY_SCHEDULE, + execution: ExecutionPlan | None = None, +) -> PausedReplay: """Rebuild the paused replay a snapshot was captured from. ``dataset`` is the replay's input, supplied back as ``objects`` are; the snapshot records what it must be, and anything else is refused before any - live object is touched. The run is rebuilt by + live object is touched. ``schedule`` (v4.1.1) is supplied back the same + way: it must be the schedule the replay was paused on, by digest, and what + of it remains is derived from the last record taken, so no input is + applied twice or lost. ``execution`` (v4.1.1, ledger FUT-009) is the plan a + replay that worked its orders in children was given, held to the book the + payload carries, whose every algorithm is rebuilt and checked against its + recorded identity. The run is rebuilt by :func:`alphalab.runtime.run_snapshot.restore`, which refuses an object configured otherwise than the run recorded (ledger PER-008). @@ -347,12 +399,48 @@ def restore(snapshot: ReplaySnapshot, objects: RunObjects, dataset: MarketDatase StateDecodeError: If the snapshot does not describe one replay paused between two records; if ``dataset`` is not the one it read -- another id, another length, another span, another record at the boundary, or - any record changed (:func:`dataset_digest`); or if a live object is - missing, of the wrong type, or configured otherwise. + any record changed (:func:`dataset_digest`); if ``schedule`` is not + the one it was paused on; if ``execution`` is not the plan its + book was produced under, or is missing, or is given for a replay + that had none; or if a live object is missing, of the wrong type, + or configured otherwise. """ _check_cursor(snapshot) _check_input(snapshot, dataset) + supplied = schedule.digest if schedule else None + if supplied != snapshot.schedule_digest: + if snapshot.schedule_digest is None: + raise _refuse( + f"it was paused with no schedule, and a schedule of {len(schedule)} entries " + "was supplied; applying it would change what the replay is." + ) + if supplied is None: + raise _refuse( + f"it was paused on schedule {snapshot.schedule_digest}, and none was " + "supplied: resuming without it would lose every input it still had to apply." + ) + raise _refuse( + f"it was paused on schedule {snapshot.schedule_digest} and schedule {supplied} " + "was supplied; resuming would apply other inputs than the replay was given." + ) + book = None + if snapshot.algorithms is None: + if execution is not None: + raise _refuse( + "it was paused with no execution plan, and one was supplied; working its " + "orders in children would change what the replay is." + ) + else: + if execution is None: + raise _refuse( + "it worked its orders in children, and no execution plan was supplied: " + "resuming without it would leave every parent held and none worked." + ) + try: + book = book_from_primitives(snapshot.algorithms, execution) + except (ExecutionPlanError, KeyError, TypeError, ValueError, ArithmeticError) as refused: + raise _refuse(f"its algorithm book cannot be continued: {refused}") from refused records = dataset.records index = snapshot.current_index expected_clock = records[index - 1].timestamp if index else snapshot.session.start_time @@ -375,7 +463,13 @@ def restore(snapshot: ReplaySnapshot, objects: RunObjects, dataset: MarketDatase (entry.event for entry in snapshot.events), snapshot.events_dropped ), ) - return PausedReplay(run=run, cursor=cursor, cursor_position=snapshot.cursor_position) + return PausedReplay( + run=run, + cursor=cursor, + cursor_position=snapshot.cursor_position, + schedule=schedule, + algorithms=book, + ) # --------------------------------------------------------------------------- @@ -446,6 +540,12 @@ def _event(value: Any, where: str) -> ReplayEventRecord: return ReplayEventRecord(tag, event) +def _optional_book(value: Any) -> Mapping[str, Any] | None: + """The book's primitives, kept as they are: decoded by :func:`restore`, given its plan.""" + + return None if value is None else as_mapping(value, "algorithms") + + def from_primitives(payload: Mapping[str, Any]) -> ReplaySnapshot: """Decode a JSON-decoded replay payload back into :class:`ReplaySnapshot`. @@ -475,5 +575,7 @@ def from_primitives(payload: Mapping[str, Any]) -> ReplaySnapshot: last_event_id=as_optional_str(require(payload, "last_event_id"), "last_event_id"), next_event_id=as_str(require(payload, "next_event_id"), "next_event_id"), events_dropped=as_int(require(payload, "events_dropped"), "events_dropped"), + schedule_digest=as_optional_str(require(payload, "schedule_digest"), "schedule_digest"), + algorithms=_optional_book(require(payload, "algorithms")), schema_version=REPLAY_SNAPSHOT_SCHEMA, ) diff --git a/alphalab/backtesting/schedule.py b/alphalab/backtesting/schedule.py new file mode 100644 index 00000000..7917423b --- /dev/null +++ b/alphalab/backtesting/schedule.py @@ -0,0 +1,613 @@ +"""Every timed input a historical run takes besides its records, and when each is applied. + +A run's market records come from its dataset. Everything else a run is told -- +external information, exchange rates, cash put into a second currency, cash a +holding earns or costs, a corporate action, a timer, a contract's final +settlement -- arrives at an instant of its own, and the canonical accounting +needs each at that instant: a rate converts nothing before it was quoted, a +dividend is booked when it is paid, a contract is settled when the exchange +says so. Until v4.1.1 :meth:`BacktestEngine.run +` took observations and nothing +else, so a two-currency book, an ``on_timer`` strategy or a perpetual's funding +had to be stepped by hand through :class:`~alphalab.runtime.run.RunEngine` +(ledger FUT-004). + +A :class:`RunSchedule` holds them all, in one order, and a +:class:`ScheduleCursor` applies them between the records -- the same object in +:meth:`BacktestEngine.run `, +:meth:`ReplayBacktest.run ` and +a loop a caller writes by hand, so all three apply every input at the same step. + +When each input is applied +-------------------------- +Every entry has an instant, and one of two places at it: + +* **before the records of its instant** -- a rate (at its ``as_of``), a + funding, a cash flow, a split. These say what the world is *at* the instant, + so the records of the instant are marked and traded against them: a split's + ratio applies to the instant's prices, a rate quoted at ``t`` converts at ``t``; +* **after the records of its instant, before its slice closes** -- an + observation (at its knowledge instant, the rule of ledger OFE-009), a timer, an + option's exercise, assignment or expiry (FUT-013), a contract settlement. These + are things that *happen* at the instant, so what a strategy asks for in + response rests until each asset's next event -- information known at ``t`` + never trades at a price printed at ``t``. + +At one instant, within one place, entries apply in the order their kinds are +listed in :class:`ScheduleKind`. For an option that order carries economics: an +exercise instructed for the expiry instant applies before the expiry resolves +what is left, and the holder's exercise before the writer's assignment. + +Entries are ordered by ``(instant, place, kind, identity)``, so ties are broken +the same way on every host. The slice of an instant is closed before anything +later than it. An entry after the data's last record is not applied: the run +could not have acted on it. + +What has been applied is a function of the last record a run was handed +--------------------------------------------------------------------------- +Every entry ordered before the records of instant ``t`` is applied before the +first record at ``t``, and nothing else is. So a run continued after the record +at ``t`` -- from a snapshot, from a pause -- has applied exactly the entries +ordered before ``(t, records)``, whatever it did with the record itself, and +:meth:`ScheduleCursor.start` with ``after=t`` resumes there, the rate table it +had included. That is why a paused replay's payload need carry only the +schedule's :attr:`RunSchedule.digest`, and a run snapshot nothing at all. + +Nothing here reads a clock or contacts anything. +""" + +from __future__ import annotations + +import hashlib +from collections.abc import Callable, Iterable, Sequence +from dataclasses import dataclass +from decimal import Decimal +from enum import IntEnum, StrEnum, unique +from itertools import pairwise +from typing import Any, Final + +from alphalab.alt_data.streaming import ObservationDelivery +from alphalab.backtesting.exceptions import BacktestError +from alphalab.common.arithmetic import ACCOUNTING_CONTEXT +from alphalab.common.constants import DEFAULT_ENCODING +from alphalab.persistence.serializer import serialize +from alphalab.portfolio.corporate_actions import ( + Assignment, + CashFlow, + CashFlowKind, + ContractSettlement, + Exercise, + OptionExpiry, + Split, +) +from alphalab.portfolio.fx import FxRate, FxRates +from alphalab.runtime.execution_pipeline import ContextFactory +from alphalab.runtime.run import RunEngine, RunState +from alphalab.strategy.events import TimerEvent + +__all__ = [ + "EMPTY_SCHEDULE", + "Funding", + "RunSchedule", + "ScheduleCursor", + "ScheduleEntry", + "ScheduleKind", + "SchedulePlace", + "ScheduleValidationError", + "ScheduledAssignment", + "ScheduledCashFlow", + "ScheduledDistribution", + "ScheduledExercise", + "ScheduledExpiry", + "ScheduledFundingRate", + "ScheduledSettlement", + "ScheduledSplit", +] + + +class ScheduleValidationError(BacktestError): + """A schedule that cannot be applied as given -- two answers to one question.""" + + +@unique +class SchedulePlace(IntEnum): + """Where at its instant an entry is applied, relative to the instant's records.""" + + BEFORE_RECORDS = 0 + #: The records of the instant themselves; no entry is placed here. + RECORDS = 1 + AFTER_RECORDS = 2 + + +@unique +class ScheduleKind(StrEnum): + """What an entry is. Its order breaks ties within one place at one instant.""" + + RATE = "rate" + FUNDING = "funding" + CASH_FLOW = "cash_flow" + DISTRIBUTION = "distribution" + FUNDING_RATE = "funding_rate" + SPLIT = "split" + OBSERVATION = "observation" + TIMER = "timer" + EXERCISE = "exercise" + ASSIGNMENT = "assignment" + EXPIRY = "expiry" + SETTLEMENT = "settlement" + + +_PLACE: Final = { + ScheduleKind.RATE: SchedulePlace.BEFORE_RECORDS, + ScheduleKind.FUNDING: SchedulePlace.BEFORE_RECORDS, + ScheduleKind.CASH_FLOW: SchedulePlace.BEFORE_RECORDS, + ScheduleKind.DISTRIBUTION: SchedulePlace.BEFORE_RECORDS, + ScheduleKind.FUNDING_RATE: SchedulePlace.BEFORE_RECORDS, + ScheduleKind.SPLIT: SchedulePlace.BEFORE_RECORDS, + ScheduleKind.OBSERVATION: SchedulePlace.AFTER_RECORDS, + ScheduleKind.TIMER: SchedulePlace.AFTER_RECORDS, + ScheduleKind.EXERCISE: SchedulePlace.AFTER_RECORDS, + ScheduleKind.ASSIGNMENT: SchedulePlace.AFTER_RECORDS, + ScheduleKind.EXPIRY: SchedulePlace.AFTER_RECORDS, + ScheduleKind.SETTLEMENT: SchedulePlace.AFTER_RECORDS, +} +_RANK: Final = {kind: rank for rank, kind in enumerate(ScheduleKind)} + + +@dataclass(frozen=True, slots=True) +class Funding: + """Cash deposited in a currency the run settles, at ``at`` (ExecutionPipeline.fund). + + How a book that settles a second currency gets cash in it: no fill converts + cash to cover itself (ADR-0035). + """ + + amount: Decimal + currency: str + at: float + + +@dataclass(frozen=True, slots=True) +class ScheduledCashFlow: + """A dividend, interest, a fee or a funding payment, booked at ``at``.""" + + flow: CashFlow + at: float + + +@dataclass(frozen=True, slots=True) +class ScheduledDistribution: + """A dividend or coupon declared per unit held, paid to whoever holds at ``at``. + + A schedule is written before the run, so it cannot know a position's size; + this states the per-unit figure an issuer declares, and the run books + ``per_unit * quantity held`` as a :class:`~alphalab.portfolio.corporate_actions.CashFlow` + when it applies -- received on a long, paid on a short, nothing on no + position. ``at`` is the instant entitlement is fixed (the ex-date's open). + """ + + asset_id: str + per_unit: Decimal + currency: str + at: float + kind: CashFlowKind = CashFlowKind.DIVIDEND + reference: str = "" + + +@dataclass(frozen=True, slots=True) +class ScheduledFundingRate: + """A perpetual's funding rate for one interval, settled at ``at``. + + The payment is ``-(quantity * multiplier * mark * rate)``: a long pays a + positive rate, a short receives it -- booked as a ``FUNDING`` cash flow in + the position's currency at the price the run last marked the contract at. + """ + + asset_id: str + rate: Decimal + at: float + reference: str = "" + + +@dataclass(frozen=True, slots=True) +class ScheduledSplit: + """A split, reverse split or stock dividend, effective at ``at``.""" + + split: Split + at: float + + +@dataclass(frozen=True, slots=True) +class ScheduledSettlement: + """A contract's final settlement, published at ``at`` (ledger FUT-012).""" + + settlement: ContractSettlement + at: float + + +@dataclass(frozen=True, slots=True) +class ScheduledExercise: + """A holder's instruction to exercise, taking effect at ``at`` (ledger FUT-013).""" + + exercise: Exercise + at: float + + +@dataclass(frozen=True, slots=True) +class ScheduledAssignment: + """Written contracts assigned at ``at``: an input, never drawn (ledger FUT-013).""" + + assignment: Assignment + at: float + + +@dataclass(frozen=True, slots=True) +class ScheduledExpiry: + """An option's expiry, resolved at ``at`` against its settlement value (ledger FUT-013).""" + + expiry: OptionExpiry + at: float + + +@dataclass(frozen=True, slots=True) +class ScheduleEntry: + """One timed input, placed. + + Attributes: + at: Its instant. + place: Before or after the records of that instant. + kind: What it is. + identity: What tells it apart from another entry of its kind at its + instant: a delivery id, a currency pair, a timer id, an asset. + item: The input itself. + """ + + at: float + place: SchedulePlace + kind: ScheduleKind + identity: str + item: Any + + @property + def key(self) -> tuple[float, int, int, str]: + """What entries are ordered by.""" + + return (self.at, int(self.place), _RANK[self.kind], self.identity) + + +def _entry(kind: ScheduleKind, at: float, identity: str, item: object) -> ScheduleEntry: + return ScheduleEntry(float(at), _PLACE[kind], kind, identity, item) + + +def _instruction(instruction: Exercise | Assignment) -> str: + """One exercise or assignment's identity: two at one instant are two only by reference.""" + + return f"{instruction.asset_id}:{instruction.reference}" + + +def _boundary(after: float | None) -> tuple[float, int] | None: + return None if after is None else (after, int(SchedulePlace.RECORDS)) + + +@dataclass(frozen=True, slots=True) +class RunSchedule: + """Every timed input of one run, in the order they are applied. + + Build one with :meth:`of`. Immutable, and identified by :attr:`digest`, so + a paused replay can hold its continuation to the schedule it was paused on. + + Attributes: + entries: Every entry, ordered by :attr:`ScheduleEntry.key`. + rate_max_age_seconds: How stale a rate may be when it is used, carried + onto the rate table; ``None`` checks nothing -- the tolerance is a + desk's, as :meth:`~alphalab.portfolio.fx_feed.FxFeed.initialize` + states. + """ + + entries: tuple[ScheduleEntry, ...] = () + rate_max_age_seconds: float | None = None + + @classmethod + def of( + cls, + *, + observations: Iterable[ObservationDelivery[Any]] = (), + rates: Iterable[FxRate] = (), + fundings: Iterable[Funding] = (), + cash_flows: Iterable[ScheduledCashFlow] = (), + distributions: Iterable[ScheduledDistribution] = (), + funding_rates: Iterable[ScheduledFundingRate] = (), + splits: Iterable[ScheduledSplit] = (), + timers: Iterable[TimerEvent] = (), + settlements: Iterable[ScheduledSettlement] = (), + rate_max_age_seconds: float | None = None, + exercises: Iterable[ScheduledExercise] = (), + assignments: Iterable[ScheduledAssignment] = (), + expiries: Iterable[ScheduledExpiry] = (), + ) -> RunSchedule: + """A schedule of the inputs given, placed and ordered. + + Raises: + ScheduleValidationError: If two entries of one kind name the same + thing at the same instant -- one observation delivered twice, two + rates for one pair as of one instant, one timer fired twice, a + contract settled twice. Each is two answers to one question, + and picking one would be arbitrary. + """ + + entries = [ + *(_entry(ScheduleKind.OBSERVATION, d.known_at, d.delivery_id, d) for d in observations), + *(_entry(ScheduleKind.RATE, r.as_of, f"{r.base}/{r.quote}", r) for r in rates), + *(_entry(ScheduleKind.FUNDING, f.at, f"{f.currency}:{f.amount}", f) for f in fundings), + *( + _entry( + ScheduleKind.CASH_FLOW, + c.at, + f"{c.flow.asset_id}:{c.flow.kind.value}:{c.flow.reference}:{c.flow.amount}", + c, + ) + for c in cash_flows + ), + *( + _entry(ScheduleKind.DISTRIBUTION, d.at, f"{d.asset_id}:{d.kind.value}", d) + for d in distributions + ), + *(_entry(ScheduleKind.FUNDING_RATE, f.at, f.asset_id, f) for f in funding_rates), + *(_entry(ScheduleKind.SPLIT, s.at, s.split.asset_id, s) for s in splits), + *(_entry(ScheduleKind.TIMER, t.timestamp, t.timer_id, t) for t in timers), + *(_entry(ScheduleKind.SETTLEMENT, s.at, s.settlement.asset_id, s) for s in settlements), + *(_entry(ScheduleKind.EXERCISE, e.at, _instruction(e.exercise), e) for e in exercises), + *( + _entry(ScheduleKind.ASSIGNMENT, a.at, _instruction(a.assignment), a) + for a in assignments + ), + *(_entry(ScheduleKind.EXPIRY, x.at, x.expiry.asset_id, x) for x in expiries), + ] + entries.sort(key=lambda entry: entry.key) + for previous, current in pairwise(entries): + if previous.key == current.key: + raise ScheduleValidationError( + f"Two {current.kind.value} entries name {current.identity!r} at " + f"{current.at!r}. One input cannot be applied twice, and two values " + "for it cannot both be right; supply one." + ) + return cls(tuple(entries), rate_max_age_seconds) + + def __len__(self) -> int: + return len(self.entries) + + def __bool__(self) -> bool: + return bool(self.entries) + + def with_observations(self, observations: Iterable[ObservationDelivery[Any]]) -> RunSchedule: + """This schedule with ``observations`` added, placed by the same rule.""" + + added = tuple(observations) + if not added: + return self + extra = RunSchedule.of(observations=added) + merged = sorted((*self.entries, *extra.entries), key=lambda entry: entry.key) + for previous, current in pairwise(merged): + if previous.key == current.key: + raise ScheduleValidationError( + f"Observation {current.identity!r} at {current.at!r} is given twice." + ) + return RunSchedule(tuple(merged), self.rate_max_age_seconds) + + @property + def digest(self) -> str: + """SHA-256 over every entry's kind, instant and canonical JSON, in order. + + What a paused replay's payload holds its schedule to. Built from the + encoder every payload uses, so it is the same on every host. + """ + + digest = hashlib.sha256() + digest.update(repr(self.rate_max_age_seconds).encode(DEFAULT_ENCODING)) + for entry in self.entries: + line = f"{entry.kind.value}|{entry.at!r}|{serialize(entry.item)}\n" + digest.update(line.encode(DEFAULT_ENCODING)) + return digest.hexdigest() + + def position_after(self, after: float | None) -> int: + """How many entries a run handed records up to ``after`` has applied. + + Every entry ordered before the records of ``after``'s instant; none for + a run handed no record (``after`` is ``None``). + """ + + boundary = _boundary(after) + if boundary is None: + return 0 + low, high = 0, len(self.entries) + while low < high: + middle = (low + high) // 2 + if self.entries[middle].key[:2] < boundary: + low = middle + 1 + else: + high = middle + return low + + def rates_through(self, position: int) -> FxRates: + """The rate table the first ``position`` entries make.""" + + table = FxRates(max_age_seconds=self.rate_max_age_seconds) + for entry in self.entries[:position]: + if entry.kind is ScheduleKind.RATE: + table = table.with_rate(entry.item) + return table + + +#: A schedule with nothing in it: a run of records alone, as before v4.1.1. +EMPTY_SCHEDULE: Final = RunSchedule() + + +@dataclass(frozen=True, slots=True) +class ScheduleCursor: + """Where a run stands in its schedule, and the rate table it has built (v4.1.1). + + Immutable: each call returns the cursor after it with the run state it + produced. Every historical driver, and a loop written by hand, takes the + same three steps -- :meth:`start`, :meth:`before` each record, :meth:`finish` + at the end of the data:: + + cursor = ScheduleCursor.start(schedule) + for record in records: + cursor, state = cursor.before(state, record.timestamp, contexts) + state = RunEngine.close_slice(state, contexts, cursor.rates, before=record.timestamp) + state, _ = RunEngine.advance(state, record, contexts, rates=cursor.rates) + cursor, state = cursor.finish(state, contexts) + + Attributes: + schedule: The schedule. + position: How many of its entries have been applied. + rates: The rate table those entries made. + """ + + schedule: RunSchedule + position: int + rates: FxRates + + @classmethod + def start(cls, schedule: RunSchedule, after: float | None = None) -> ScheduleCursor: + """A cursor for a run handed records up to ``after`` -- none, by default. + + ``after`` is the timestamp of the last record the run was handed, + processed or skipped: to continue a run from a snapshot or a pause, pass + the timestamp of the record it stopped after. + """ + + position = schedule.position_after(after) + return cls(schedule, position, schedule.rates_through(position)) + + @property + def pending(self) -> Sequence[ScheduleEntry]: + """The entries not yet applied, in order.""" + + return self.schedule.entries[self.position :] + + def before( + self, state: RunState, timestamp: float, context_factory: ContextFactory + ) -> tuple[ScheduleCursor, RunState]: + """Apply, in order, every entry ordered before the records of ``timestamp``. + + Called with the timestamp of the record about to be handed to the run. + """ + + boundary = (timestamp, int(SchedulePlace.RECORDS)) + return self._apply_while(state, context_factory, lambda entry: entry.key[:2] < boundary) + + def finish( + self, state: RunState, context_factory: ContextFactory, last: float | None = None + ) -> tuple[ScheduleCursor, RunState]: + """At the end of the data: apply what is at or before its last record's instant. + + ``last`` is the timestamp of the last record the run was handed -- + processed or skipped, as :meth:`start`'s ``after`` is; the drivers pass + it. Not given, it is ``state.last_record_timestamp``, the last record + processed, which is the same unless the data ends in skipped records. + An entry after it is beyond the data, and is not applied. + """ + + end = state.last_record_timestamp if last is None else last + if end is None: + return self, state + return self._apply_while(state, context_factory, lambda entry: entry.at <= end) + + def _apply_while( + self, + state: RunState, + context_factory: ContextFactory, + due: Callable[[ScheduleEntry], bool], + ) -> tuple[ScheduleCursor, RunState]: + entries = self.schedule.entries + position, rates = self.position, self.rates + while position < len(entries) and due(entries[position]): + entry = entries[position] + # The slice of an instant closes before anything later than it. + state = RunEngine.close_slice(state, context_factory, rates, before=entry.at) + rates, state = _apply(entry, state, rates, context_factory) + position += 1 + if position == self.position: + return self, state + return ScheduleCursor(self.schedule, position, rates), state + + +def _apply( + entry: ScheduleEntry, state: RunState, rates: FxRates, context_factory: ContextFactory +) -> tuple[FxRates, RunState]: + """Apply one entry through the run's own operation for it.""" + + item = entry.item + kind = entry.kind + if kind is ScheduleKind.RATE: + return rates.with_rate(item), state + if kind is ScheduleKind.FUNDING: + return rates, RunEngine.fund(state, item.amount, item.currency, item.at, rates) + if kind is ScheduleKind.CASH_FLOW: + return rates, RunEngine.apply_cash_flow(state, item.flow, item.at, rates) + if kind is ScheduleKind.DISTRIBUTION: + flow = _distribution(state, item) + if flow is None: + return rates, state + return rates, RunEngine.apply_cash_flow(state, flow, item.at, rates) + if kind is ScheduleKind.FUNDING_RATE: + flow = _funding(state, item) + if flow is None: + return rates, state + return rates, RunEngine.apply_cash_flow(state, flow, item.at, rates) + if kind is ScheduleKind.SPLIT: + return rates, RunEngine.apply_split(state, item.split, item.at, rates) + if kind is ScheduleKind.OBSERVATION: + return rates, RunEngine.deliver_observation(state, item, context_factory, rates) + if kind is ScheduleKind.TIMER: + return rates, RunEngine.fire_timer(state, item, context_factory, rates) + if kind is ScheduleKind.EXERCISE: + return rates, RunEngine.exercise(state, item.exercise, item.at, rates) + if kind is ScheduleKind.ASSIGNMENT: + return rates, RunEngine.assign(state, item.assignment, item.at, rates) + if kind is ScheduleKind.EXPIRY: + return rates, RunEngine.expire_option(state, item.expiry, item.at, rates) + return rates, RunEngine.settle_contract(state, item.settlement, item.at, rates) + + +def _held(state: RunState, asset_id: str) -> Any: + position = state.pipeline.portfolio.positions.get(asset_id) + return None if position is None or position.quantity == 0 else position + + +def _distribution(state: RunState, item: ScheduledDistribution) -> CashFlow | None: + """The cash a per-unit distribution pays the position held now, or ``None`` for none.""" + + position = _held(state, item.asset_id) + if position is None: + return None + amount = ACCOUNTING_CONTEXT.multiply(item.per_unit, position.quantity) + if amount == 0: + return None + return CashFlow(item.kind, amount, item.currency, item.asset_id, item.reference) + + +def _funding(state: RunState, item: ScheduledFundingRate) -> CashFlow | None: + """What one funding interval costs the position held now, at the run's last mark. + + Raises: + ScheduleValidationError: If the run holds the contract but has never + priced it -- a funding payment needs a mark. + """ + + position = _held(state, item.asset_id) + if position is None: + return None + mark = state.pipeline.market_prices.get(item.asset_id) + if mark is None: + raise ScheduleValidationError( + f"A funding rate for {item.asset_id} at {item.at!r} needs the contract's mark, " + "and the run has none." + ) + ctx = ACCOUNTING_CONTEXT + notional = ctx.multiply(ctx.multiply(position.quantity, position.multiplier), mark) + amount = -ctx.multiply(notional, item.rate) + if amount == 0: + return None + return CashFlow(CashFlowKind.FUNDING, amount, position.currency, item.asset_id, item.reference) diff --git a/alphalab/backtesting/state.py b/alphalab/backtesting/state.py index 5a20e412..57e90f42 100644 --- a/alphalab/backtesting/state.py +++ b/alphalab/backtesting/state.py @@ -6,12 +6,17 @@ drivers report once that state is finished. Storing a second copy of a fact the run already carries is how a result comes to disagree with the run it describes -- which is exactly what happened to ``dataset_id`` before v2.14, and why every -attribute below reads through. +attribute below reads through. A run given an execution plan (v4.1.1, ledger +FUT-009) returns an :class:`AlgorithmicResult`: the same projection, and beside +it the plan's :class:`~alphalab.backtesting.algorithmic.AlgorithmBook` -- driver +state the run does not hold, as a schedule's cursor is not, so it is no second +copy of anything, and the run's record and ``result_id`` are what they were. """ from __future__ import annotations from dataclasses import dataclass +from typing import TYPE_CHECKING from alphalab.analytics.engine import PortfolioSnapshot from alphalab.analytics.report import PerformanceReport @@ -25,7 +30,10 @@ from alphalab.runtime.execution_pipeline import ExecutionPipelineState, UnpricedAsset from alphalab.runtime.run import RunConfig, RunState, RunStep, StrategyFailure -__all__ = ["BacktestResult", "ReplayResult"] +if TYPE_CHECKING: + from alphalab.backtesting.algorithmic import AlgorithmBook + +__all__ = ["AlgorithmicResult", "BacktestResult", "ReplayResult"] @dataclass(frozen=True, slots=True) @@ -183,6 +191,21 @@ def unpriced_assets(self) -> tuple[UnpricedAsset, ...]: return self.run.unpriced_assets +@dataclass(frozen=True, slots=True) +class AlgorithmicResult(BacktestResult): + """A finished run whose orders an execution plan worked in children (v4.1.1, FUT-009). + + Everything :class:`BacktestResult` reads, through the run, and the book the + run does not hold. + + Attributes: + algorithms: Every parent the plan's algorithms worked, work, or refused, + with their children and every child fill. + """ + + algorithms: AlgorithmBook + + @dataclass(frozen=True, slots=True) class ReplayResult: """A replay run: the backtest result plus the replay cursor it was driven by.""" @@ -202,6 +225,13 @@ def dataset_id(self) -> str | None: return self.backtest.dataset_id + @property + def algorithms(self) -> AlgorithmBook | None: + """The execution plan's book, for a replay given one (v4.1.1); ``None`` otherwise.""" + + backtest = self.backtest + return backtest.algorithms if isinstance(backtest, AlgorithmicResult) else None + @property def unpriced_assets(self) -> tuple[UnpricedAsset, ...]: """What the replayed run declined to trade, read from the run it wraps.""" diff --git a/alphalab/core/enums.py b/alphalab/core/enums.py index 072a752b..30fa3a0f 100644 --- a/alphalab/core/enums.py +++ b/alphalab/core/enums.py @@ -53,6 +53,15 @@ class AssetType(StrEnum): FOREX = "forex" CRYPTO = "crypto" CASH = "cash" + #: v4.1.1 (ledger FUT-010): a fixed-income instrument. Declares its + #: economics -- a unit's multiplier on the quoted price -- and its maturity + #: as its last trading instant; redeemed by a contract settlement. + BOND = "bond" + #: v4.1.1 (ledger FUT-013): a reference value -- a benchmark index level a + #: cash-settled contract settles against. Valued one unit of price per + #: point unless it declares otherwise, and never traded: a request in it is + #: refused before an order exists. + INDEX = "index" @unique diff --git a/alphalab/instrument/__init__.py b/alphalab/instrument/__init__.py index aa87936d..87d70e71 100644 --- a/alphalab/instrument/__init__.py +++ b/alphalab/instrument/__init__.py @@ -112,6 +112,13 @@ register_instruments, sector_as_of, ) +from alphalab.instrument.terms import ( + AutomaticExercise, + ContractTerms, + Deliverable, + FutureTerms, + OptionTerms, +) __all__ = [ "ALPHALAB_INSTRUMENT_NAMESPACE", @@ -119,16 +126,21 @@ "INSTRUMENT_KEY_SCHEME", "OPERATOR", "SECTOR", + "AutomaticExercise", "ClassificationHistory", + "ContractTerms", "DatedAlias", + "Deliverable", "DimensionClassification", "DimensionHistory", + "FutureTerms", "InstrumentEconomics", "InstrumentError", "InstrumentInputError", "InstrumentRecord", "InstrumentRegistrationError", "InstrumentRegistry", + "OptionTerms", "SectorClassification", "SettlementModel", "canonical_instrument_key", diff --git a/alphalab/instrument/economics.py b/alphalab/instrument/economics.py index 1902ba65..36fe424e 100644 --- a/alphalab/instrument/economics.py +++ b/alphalab/instrument/economics.py @@ -53,6 +53,11 @@ def economics_for( return declared if asset_type in _FULLY_PAID: return CASH_EQUITY + if asset_type is AssetType.INDEX: + # A level, not a holding: one point is one unit of the price it is quoted + # in, which is what an index level is. Nothing is paid for it -- it is + # never traded -- so this is its value, not an assumed settlement. + return CASH_EQUITY raise InstrumentInputError( f"{name} is a {asset_type.value}, and declares no economics: its multiplier and " "settlement cannot be assumed, so it cannot be booked. Declare InstrumentEconomics " diff --git a/alphalab/instrument/record.py b/alphalab/instrument/record.py index a0d0a3e2..4ecfd3eb 100644 --- a/alphalab/instrument/record.py +++ b/alphalab/instrument/record.py @@ -66,10 +66,12 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass, field +from alphalab.conventions.economics import SettlementModel from alphalab.core.enums import AssetType from alphalab.instrument.economics import InstrumentEconomics from alphalab.instrument.exceptions import InstrumentInputError from alphalab.instrument.identity import canonical_instrument_key, derive_asset_id +from alphalab.instrument.terms import ContractTerms, FutureTerms, OptionTerms __all__ = ["DatedAlias", "InstrumentRecord", "normalize_key_field", "normalize_sector_label"] @@ -318,6 +320,21 @@ class InstrumentRecord: equity for the asset types that are one and refuses for a future or an option. Not part of the identity key: an exchange revising a lot size re-identifies nothing. + last_trade_at: The last instant, in Unix seconds, at which the + instrument trades -- a future's or an option's expiry -- or ``None`` + for one that does not expire (v4.1.1, ledger FUT-012). After it, the + execution path skips the instrument's records and refuses its orders, + and a position still held must be closed by a + :class:`~alphalab.portfolio.corporate_actions.ContractSettlement` + before the run finishes. Not part of the identity key, like + ``economics``. + terms: What the contract does at exercise, assignment, expiry and + delivery -- :class:`~alphalab.instrument.terms.OptionTerms` for an + option, :class:`~alphalab.instrument.terms.FutureTerms` for a future + -- or ``None``, when a contract is settled at a price the caller + states, as every contract was before v4.1.1 (ledger FUT-013). + Declared only with the contract's economics and last trading + instant; not part of the identity key. asset_id: The derived canonical identifier. UUIDv5, and therefore accepted by :func:`~alphalab.core.ids.validate_uuid_id`. """ @@ -330,6 +347,8 @@ class InstrumentRecord: aliases: Mapping[str, str] = field(default_factory=dict) dated_aliases: tuple[DatedAlias, ...] = () economics: InstrumentEconomics | None = None + last_trade_at: float | None = None + terms: ContractTerms | None = None asset_id: str = field(init=False) def __post_init__(self) -> None: @@ -341,6 +360,14 @@ def __post_init__(self) -> None: raise InstrumentInputError( f"economics must be InstrumentEconomics, got {type(self.economics).__name__}." ) + if self.last_trade_at is not None and ( + isinstance(self.last_trade_at, bool) + or not isinstance(self.last_trade_at, int | float) + or not math.isfinite(self.last_trade_at) + ): + raise InstrumentInputError( + f"last_trade_at is an instant in Unix seconds, got {self.last_trade_at!r}." + ) symbol = normalize_key_field(self.symbol, "symbol") exchange = normalize_key_field(self.exchange, "exchange") @@ -356,6 +383,8 @@ def __post_init__(self) -> None: "asset_id", derive_asset_id(self.asset_type, exchange, symbol, currency), ) + if self.terms is not None: + _require_consistent_terms(self) @property def canonical_key(self) -> str: @@ -366,3 +395,41 @@ def canonical_key(self) -> str: """ return canonical_instrument_key(self.asset_type, self.exchange, self.symbol, self.currency) + + +def _require_consistent_terms(record: InstrumentRecord) -> None: + """Terms that cannot belong to this record are refused at declaration (FUT-013).""" + + terms = record.terms + named = f"{record.symbol} on {record.exchange}" + if isinstance(terms, OptionTerms): + expected, settles = AssetType.OPTION, SettlementModel.OPTION_PREMIUM + elif isinstance(terms, FutureTerms): + expected, settles = AssetType.FUTURE, SettlementModel.FUTURES_VARIATION + else: + raise InstrumentInputError( + f"terms are OptionTerms or FutureTerms, got {type(terms).__name__}." + ) + if record.asset_type is not expected: + raise InstrumentInputError( + f"{named} is a {record.asset_type.value}; {type(terms).__name__} are a " + f"{expected.value}'s terms." + ) + if record.economics is None or record.economics.settlement is not settles: + raise InstrumentInputError( + f"{named} declares terms and not economics settled as {settles.value}: the " + "terms read the contract's multiplier, which only its economics state." + ) + if record.last_trade_at is None: + raise InstrumentInputError( + f"{named} declares terms and no last trading instant; a contract with a lifecycle " + "stops trading at a stated instant." + ) + if isinstance(terms, OptionTerms) and terms.expires_at < record.last_trade_at: + raise InstrumentInputError( + f"{named} stops trading at {record.last_trade_at!r} and expires at " + f"{terms.expires_at!r}: exercise cannot end before trading does." + ) + deliverable = terms.deliverable + if deliverable is not None and deliverable.asset_id == record.asset_id: + raise InstrumentInputError(f"{named} cannot deliver itself.") diff --git a/alphalab/instrument/registry.py b/alphalab/instrument/registry.py index 90a50710..1439ecd1 100644 --- a/alphalab/instrument/registry.py +++ b/alphalab/instrument/registry.py @@ -140,6 +140,11 @@ class InstrumentRegistry: An index over the records and ``dimensions`` -- derived, never declared: left ``None`` it is built from them, and every write here keeps it true. + expiring: ``asset_id`` -> last trading instant, for every instrument + declaring one (v4.1.1, ledger FUT-012) -- what the execution path + reads on every record to refuse an expired contract's price, so a + registry with no expiring instrument costs that path nothing. An + index, derived like ``members``. """ instruments: PersistentMap[str, InstrumentRecord] = field(default_factory=PersistentMap) @@ -154,6 +159,7 @@ class InstrumentRegistry: default_factory=PersistentMap ) members: PersistentMap[str, PersistentMap[str, PersistentMap[str, None]]] | None = None + expiring: PersistentMap[str, float] | None = None def __post_init__(self) -> None: # The containers' own types only -- inspecting the entries here would @@ -173,6 +179,18 @@ def __post_init__(self) -> None: # Built once, when a registry is declared from mappings; every write # below passes the index on, kept true, so this never runs again. object.__setattr__(self, "members", _index(self.instruments, self.dimensions)) + if self.expiring is None: + object.__setattr__( + self, + "expiring", + PersistentMap( + { + asset_id: record.last_trade_at + for asset_id, record in self.instruments.items() + if record.last_trade_at is not None + } + ), + ) def resolve(self, provider: str, symbol: str) -> str | None: """The ``asset_id`` ``provider`` means by ``symbol``, or ``None``. @@ -410,6 +428,11 @@ def register_instrument( registry, instruments=registry.instruments.set(record.asset_id, record), members=_relabelled(registry.members, SECTOR, record.asset_id, None, record.sector), + expiring=( + registry.expiring + if record.last_trade_at is None or registry.expiring is None + else registry.expiring.set(record.asset_id, record.last_trade_at) + ), ) ) for provider, symbol in record.aliases.items(): diff --git a/alphalab/instrument/snapshot.py b/alphalab/instrument/snapshot.py index 5559b3a7..8f3bde6a 100644 --- a/alphalab/instrument/snapshot.py +++ b/alphalab/instrument/snapshot.py @@ -86,6 +86,7 @@ register_dated_alias, register_instrument, ) +from alphalab.instrument.terms import ContractTerms, terms_from_primitives, terms_to_primitives from alphalab.persistence.decode import ( as_float, as_mapping, @@ -123,8 +124,10 @@ #: economics (ledger ACC-005); see the module docstring. Version 3 (v3.12) #: carries each instrument's classifications along every dimension but sector #: (ledger OFE-001); the index of each dimension's buckets is rebuilt, not -#: carried. -INSTRUMENT_SNAPSHOT_SCHEMA: Final = 3 +#: carried. Version 4 (v4.1.1) carries each instrument's last trading instant +#: (ledger FUT-012) and its contract terms (FUT-013); every earlier record +#: declared neither. +INSTRUMENT_SNAPSHOT_SCHEMA: Final = 4 _SUBSYSTEM: Final = "instrument" @@ -189,6 +192,10 @@ class InstrumentRegistryRecord: #: Dimension -> every classification along it, oldest first; sorted by #: dimension so a payload does not depend on the order they were declared in. dimensions: Mapping[str, tuple[DimensionRecord, ...]] = field(default_factory=dict) + last_trade_at: float | None = None + #: The contract's terms as :func:`~alphalab.instrument.terms.terms_to_primitives` + #: writes them, or ``None``. + terms: Mapping[str, Any] | None = None @dataclass(frozen=True, slots=True) @@ -272,6 +279,8 @@ def capture(registry: InstrumentRegistry) -> InstrumentRegistrySnapshot: for dimension, histories in sorted(registry.dimensions.items()) if asset_id in histories }, + last_trade_at=record.last_trade_at, + terms=terms_to_primitives(record.terms), ) ) return InstrumentRegistrySnapshot( @@ -321,6 +330,8 @@ def restore(snapshot: InstrumentRegistrySnapshot) -> InstrumentRegistry: aliases=dict(entry.aliases), dated_aliases=tuple(_dated(item) for item in entry.dated_aliases), economics=entry.economics, + last_trade_at=entry.last_trade_at, + terms=_terms(entry.terms, f"{entry.symbol}.terms"), ) if record.asset_id in registry.instruments: raise StateDecodeError( @@ -470,9 +481,28 @@ def _record(value: Any, index: int) -> InstrumentRegistryRecord: ), economics=_economics(require(payload, "economics"), f"{where}.economics"), dimensions=_dimensions(require(payload, "dimensions"), f"{where}.dimensions"), + last_trade_at=_optional_instant( + require(payload, "last_trade_at"), f"{where}.last_trade_at" + ), + terms=_optional_mapping(require(payload, "terms"), f"{where}.terms"), ) +def _optional_mapping(value: Any, where: str) -> Mapping[str, Any] | None: + return None if value is None else as_mapping(value, where) + + +def _terms(value: Mapping[str, Any] | None, where: str) -> ContractTerms | None: + try: + return terms_from_primitives(value, where) + except InstrumentInputError as exc: + raise StateDecodeError(str(exc)) from exc + + +def _optional_instant(value: Any, where: str) -> float | None: + return None if value is None else as_float(value, where) + + def _economics(value: Any, where: str) -> InstrumentEconomics | None: if value is None: return None @@ -522,6 +552,21 @@ def _v2_to_v3(payload: dict[str, Any]) -> dict[str, Any]: } +def _v3_to_v4(payload: dict[str, Any]) -> dict[str, Any]: + """A version-3 instrument declared no last trading instant and no contract terms. + + Nothing before v4.1.1 could declare either (ledgers FUT-012, FUT-013), so + every record reads as one that does not expire -- what the payload says. + """ + + return { + **payload, + "instruments": [ + {**entry, "last_trade_at": None, "terms": None} for entry in payload["instruments"] + ], + } + + INSTRUMENT_SCHEMA_HISTORY = SchemaHistory( _SUBSYSTEM, INSTRUMENT_SNAPSHOT_SCHEMA, @@ -538,6 +583,12 @@ def _v2_to_v3(payload: dict[str, Any]) -> dict[str, Any]: "than sector; no earlier registry had any", upgrade=_v2_to_v3, ), + SchemaStep( + 3, + "version 4 carries each instrument's last trading instant and contract terms; " + "no earlier registry declared either", + upgrade=_v3_to_v4, + ), ), ) diff --git a/alphalab/instrument/terms.py b/alphalab/instrument/terms.py new file mode 100644 index 00000000..fb8f23dd --- /dev/null +++ b/alphalab/instrument/terms.py @@ -0,0 +1,352 @@ +"""What a derivative contract says happens at exercise, assignment, expiry and delivery (FUT-013). + +:class:`~alphalab.conventions.economics.InstrumentEconomics` says what one unit +of a contract is worth and how it is paid for; ``InstrumentRecord.last_trade_at`` +says when it stops trading. Neither says what the contract *does* -- whether an +option is exercised into the underlying or into cash, against which instrument, +how many units, when it may be exercised, and what the clearing house does with +it at expiry. Until v4.1.1 that was the caller's arithmetic: a contract settled at +a price the caller computed (FUT-012), and nothing could be delivered. + +:class:`OptionTerms` and :class:`FutureTerms` are the declaration the execution +path reads instead. They ride on the +:class:`~alphalab.instrument.record.InstrumentRecord` beside ``economics`` and, +like it, outside the identity key: terms describe a contract without changing +what it is. + +Nothing is inferred from a country, an exchange or a currency +-------------------------------------------------------------- + +An equity option in one market is American and physically settled, an index +option on the same venue European and cash-settled, a single-stock option in +another market European and cash-settled, a weekly contract on a third expires at +a different local time on a different weekday. None of that is a property of +the market's name, so every term is **declared**: the exercise style, the +settlement style, the deliverable and its quantity per contract, the expiry +instant, and the automatic-exercise rule the clearing house applies. Nothing has +a default that could stand in for a convention; a term the path needs and the +contract does not declare is refused where it is needed. + +Where the instants come from +---------------------------- + +Every instant here is a UTC instant, as every instant in AlphaLab is. Which +instant a market's "expiry at the close on the third Friday" is depends on its +calendar and time zone, which :class:`~alphalab.data.calendar.MarketCalendar` +owns; :func:`alphalab.runtime.calendars.session_close` and +:func:`~alphalab.runtime.calendars.settles_at` turn a local date and a declared +:class:`~alphalab.conventions.settlement.SettlementRule` into the instant, so a +market whose sessions, holidays and daylight-saving rules differ needs a +different calendar, not different code. + +What is supported, and what is refused +--------------------------------------- + +* Options: American and European exercise; cash or physical settlement; one + deliverable instrument, in any declared quantity per contract (an adjusted + contract delivering 150 units against a multiplier of 100 included); an + automatic-exercise threshold, or none. +* Futures: cash settlement at the final price, or physical delivery of one + deliverable at the final price. + +Not representable, and so not supported: a deliverable of several instruments +or with cash in lieu, a Bermudan or other windowed exercise style, a contract +whose strike is in another currency than the deliverable is paid in. Each would +be a further term, declared, not a branch for a market. +""" + +from __future__ import annotations + +import math +from collections.abc import Mapping +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from typing import Any + +from alphalab.instrument.exceptions import InstrumentInputError +from alphalab.options.enums import ExerciseStyle, OptionType +from alphalab.options.expiration import SettlementStyle + +__all__ = [ + "AutomaticExercise", + "ContractTerms", + "Deliverable", + "FutureTerms", + "OptionTerms", + "terms_from_primitives", + "terms_to_primitives", +] + +_ZERO = Decimal("0") + + +def _instant(value: Any, name: str) -> None: + if isinstance(value, bool) or not isinstance(value, int | float) or not math.isfinite(value): + raise InstrumentInputError(f"{name} is an instant in Unix seconds, got {value!r}.") + + +def _positive(value: Any, name: str) -> None: + if not isinstance(value, Decimal) or not value.is_finite() or value <= _ZERO: + raise InstrumentInputError(f"{name} is a positive finite Decimal, got {value!r}.") + + +@dataclass(frozen=True, slots=True) +class Deliverable: + """What one contract delivers: ``units`` of the instrument ``asset_id``. + + The underlying's own :class:`~alphalab.instrument.record.InstrumentRecord` + says what that instrument is -- its currency, its economics, its lot grid. + Here is only how much of it one contract moves, which is the contract's term + and frequently not its multiplier once a corporate action has adjusted it. + """ + + asset_id: str + units: Decimal + + def __post_init__(self) -> None: + if not isinstance(self.asset_id, str) or not self.asset_id.strip(): + raise InstrumentInputError("A deliverable names the instrument it delivers.") + _positive(self.units, "A deliverable's units per contract") + + +@dataclass(frozen=True, slots=True) +class AutomaticExercise: + """The clearing house's rule at expiry: exercise what is in the money by ``threshold``. + + At expiry every long position whose intrinsic value per unit of price is at + least ``threshold`` -- and above zero: at the money is never exercised -- is + exercised, and the short positions are assigned by the same rule, which + stands in for the holders on the other side. A threshold of zero exercises + everything in the money. A contract with no rule exercises nothing at expiry + that was not instructed. + """ + + threshold: Decimal + + def __post_init__(self) -> None: + if not isinstance(self.threshold, Decimal) or not self.threshold.is_finite(): + raise InstrumentInputError( + f"An exercise threshold is a finite Decimal, got {self.threshold!r}." + ) + if self.threshold < _ZERO: + raise InstrumentInputError( + f"An exercise threshold of {self.threshold} would exercise options out of the " + "money; it is zero or more." + ) + + def exercises(self, intrinsic: Decimal) -> bool: + """Whether an option ``intrinsic`` in the money per unit of price is exercised.""" + + return intrinsic > _ZERO and intrinsic >= self.threshold + + +@dataclass(frozen=True, slots=True) +class OptionTerms: + """An option's exercise, settlement and delivery terms. + + Attributes: + option_type: Call or put. + strike: The strike, per unit of price -- the price the contract is + quoted in, so ``strike x multiplier`` is what one contract pays or + receives against delivery. + exercise_style: ``AMERICAN`` -- any instant from ``exercisable_from`` to + ``expires_at`` -- or ``EUROPEAN`` -- from the last trading instant to + ``expires_at`` only. + settlement: ``PHYSICAL`` -- the deliverable moves against the strike -- + or ``CASH`` -- the intrinsic value is paid. + deliverable: What one contract delivers, or is cash-settled against. + expires_at: The last instant the contract may be exercised, at or after + its last trading instant: exercise notices are commonly accepted + after trading has stopped. + automatic_exercise: The clearing house's rule at expiry, or ``None``. + exercisable_from: For an American contract, the first instant it may be + exercised; ``None`` means from whenever it is held. Refused for a + European contract, whose window is fixed. + """ + + option_type: OptionType + strike: Decimal + exercise_style: ExerciseStyle + settlement: SettlementStyle + deliverable: Deliverable + expires_at: float + automatic_exercise: AutomaticExercise | None + exercisable_from: float | None = None + + def __post_init__(self) -> None: + if not isinstance(self.option_type, OptionType): + raise InstrumentInputError(f"option_type is an OptionType, got {self.option_type!r}.") + _positive(self.strike, "A strike") + if not isinstance(self.exercise_style, ExerciseStyle): + raise InstrumentInputError( + f"exercise_style is an ExerciseStyle, got {self.exercise_style!r}." + ) + if not isinstance(self.settlement, SettlementStyle): + raise InstrumentInputError(f"settlement is a SettlementStyle, got {self.settlement!r}.") + if not isinstance(self.deliverable, Deliverable): + raise InstrumentInputError(f"deliverable is a Deliverable, got {self.deliverable!r}.") + _instant(self.expires_at, "expires_at") + if self.automatic_exercise is not None and not isinstance( + self.automatic_exercise, AutomaticExercise + ): + raise InstrumentInputError( + f"automatic_exercise is an AutomaticExercise or None, got " + f"{self.automatic_exercise!r}." + ) + if self.exercisable_from is not None: + if self.exercise_style is not ExerciseStyle.AMERICAN: + raise InstrumentInputError( + f"A {self.exercise_style.name} option is exercised at expiry only; an " + "exercisable_from is an American contract's term." + ) + _instant(self.exercisable_from, "exercisable_from") + if self.exercisable_from > self.expires_at: + raise InstrumentInputError( + f"An option exercisable from {self.exercisable_from!r} expires at " + f"{self.expires_at!r}: it could never be exercised." + ) + + def exercise_window(self, last_trade_at: float) -> tuple[float, float]: + """The instants an instruction to exercise may take effect in, inclusive.""" + + if self.exercise_style is ExerciseStyle.EUROPEAN: + return last_trade_at, self.expires_at + start = -math.inf if self.exercisable_from is None else self.exercisable_from + return start, self.expires_at + + def intrinsic( + self, underlying_value: Decimal, multiplier: Decimal, deliverable_multiplier: Decimal + ) -> Decimal: + """Intrinsic value per unit of the contract's price, never negative. + + One contract delivers ``units`` of the deliverable, each worth its quoted + ``underlying_value`` times the deliverable's own multiplier, against + ``strike x multiplier``; per unit of price that is + ``max(S x units x m_deliverable / m - K, 0)`` for a call and its mirror for + a put. For a share option delivering as many shares as its multiplier, + and for an option on one future with the future's multiplier, it is + ``max(S - K, 0)``. + """ + + per_price_unit = ( + underlying_value * self.deliverable.units * deliverable_multiplier / multiplier + ) + if self.option_type is OptionType.CALL: + return max(per_price_unit - self.strike, _ZERO) + return max(self.strike - per_price_unit, _ZERO) + + +@dataclass(frozen=True, slots=True) +class FutureTerms: + """A future's settlement at expiry. + + Attributes: + settlement: ``CASH`` -- closed at the final settlement price, as every + future was before v4.1.1 -- or ``PHYSICAL`` -- closed there, and the + deliverable then moves against the final price. + deliverable: What one contract delivers; required for ``PHYSICAL`` and + refused for ``CASH``. + """ + + settlement: SettlementStyle + deliverable: Deliverable | None + + def __post_init__(self) -> None: + if not isinstance(self.settlement, SettlementStyle): + raise InstrumentInputError(f"settlement is a SettlementStyle, got {self.settlement!r}.") + if self.settlement is SettlementStyle.PHYSICAL and self.deliverable is None: + raise InstrumentInputError( + "A physically settled future names its deliverable; nothing can be delivered " + "without one." + ) + if self.settlement is SettlementStyle.CASH and self.deliverable is not None: + raise InstrumentInputError( + "A cash-settled future delivers nothing; a deliverable would never move." + ) + if self.deliverable is not None and not isinstance(self.deliverable, Deliverable): + raise InstrumentInputError(f"deliverable is a Deliverable, got {self.deliverable!r}.") + + +type ContractTerms = OptionTerms | FutureTerms + + +# --------------------------------------------------------------------------- # +# Persistence +# --------------------------------------------------------------------------- # + + +def _deliverable_out(deliverable: Deliverable | None) -> dict[str, str] | None: + if deliverable is None: + return None + return {"asset_id": deliverable.asset_id, "units": str(deliverable.units)} + + +def terms_to_primitives(terms: ContractTerms | None) -> dict[str, Any] | None: + """The terms as plain primitives, enum members by name, amounts as text.""" + + if terms is None: + return None + if isinstance(terms, FutureTerms): + return { + "kind": "future", + "settlement": terms.settlement.name, + "deliverable": _deliverable_out(terms.deliverable), + } + rule = terms.automatic_exercise + return { + "kind": "option", + "option_type": terms.option_type.name, + "strike": str(terms.strike), + "exercise_style": terms.exercise_style.name, + "settlement": terms.settlement.name, + "deliverable": _deliverable_out(terms.deliverable), + "expires_at": terms.expires_at, + "automatic_exercise": None if rule is None else str(rule.threshold), + "exercisable_from": terms.exercisable_from, + } + + +def _deliverable_in(value: Any) -> Deliverable | None: + if value is None: + return None + return Deliverable(str(value["asset_id"]), Decimal(value["units"])) + + +def terms_from_primitives(value: Any, where: str = "terms") -> ContractTerms | None: + """Read terms :func:`terms_to_primitives` wrote. + + Raises: + InstrumentInputError: If the value is not one of the two shapes, names an + enum member this build does not have, or states terms that do not + validate. + """ + + if value is None: + return None + if not isinstance(value, Mapping): + raise InstrumentInputError(f"{where} are not contract terms: {value!r}.") + try: + kind = value["kind"] + if kind == "future": + return FutureTerms( + SettlementStyle[value["settlement"]], _deliverable_in(value["deliverable"]) + ) + if kind == "option": + rule = value["automatic_exercise"] + start = value["exercisable_from"] + deliverable = _deliverable_in(value["deliverable"]) + if deliverable is None: + raise InstrumentInputError(f"{where} declare an option with no deliverable.") + return OptionTerms( + option_type=OptionType[value["option_type"]], + strike=Decimal(value["strike"]), + exercise_style=ExerciseStyle[value["exercise_style"]], + settlement=SettlementStyle[value["settlement"]], + deliverable=deliverable, + expires_at=float(value["expires_at"]), + automatic_exercise=None if rule is None else AutomaticExercise(Decimal(rule)), + exercisable_from=None if start is None else float(start), + ) + except (KeyError, TypeError, ValueError, InvalidOperation) as exc: + raise InstrumentInputError(f"{where} are not contract terms: {exc}.") from exc + raise InstrumentInputError(f"{where} are of kind {value.get('kind')!r}, not option or future.") diff --git a/alphalab/lifecycle/reproducibility.py b/alphalab/lifecycle/reproducibility.py index 5d051b3a..9d1bd518 100644 --- a/alphalab/lifecycle/reproducibility.py +++ b/alphalab/lifecycle/reproducibility.py @@ -79,7 +79,9 @@ from enum import Enum, auto from typing import Any, Final, Protocol -from alphalab.backtesting.state import BacktestResult +from alphalab.backtesting.algorithmic import ExecutionPlan +from alphalab.backtesting.schedule import RunSchedule, ScheduleKind +from alphalab.backtesting.state import AlgorithmicResult, BacktestResult from alphalab.lifecycle.exceptions import LifecycleInputError from alphalab.lifecycle.fingerprint import ( EngineBuild, @@ -90,6 +92,7 @@ from alphalab.persistence.exceptions import PersistenceError from alphalab.persistence.serializer import serialize from alphalab.research.study import StudyResult, canonical_study_key +from alphalab.runtime.execution_pipeline import ExecutionRouting from alphalab.runtime.run import ExecutionMode from alphalab.runtime.run_snapshot import RunSnapshot from alphalab.runtime.run_snapshot import capture as capture_run @@ -403,6 +406,24 @@ class ReproducibilityManifest: record one -- which :func:`manifest_gaps` reports. Rendered into the identity only when recorded, so a manifest derived before v3.11 still verifies. + schedule_digest: The digest of the run's + :class:`~alphalab.backtesting.schedule.RunSchedule` -- every + observation, rate, funding, cash flow, split, timer and settlement + it was given (v4.1.1, ledger FUT-005) -- or ``None`` where the run + was given none, or the producer recorded none. Rendered into the + identity only when recorded, as ``build`` is, so every earlier + manifest keeps its id. + dataset_parts: For a run over a combined dataset + (:class:`~alphalab.api.CombinedDataset`), each part's + ``(dataset_version, content_hash)`` in the order the run read them + (v4.1.1, ledger FUT-005) -- the exact datasets a rerun is given + back; empty for a single dataset. Rendered into the identity only + when present, and checked by :func:`verify_manifest` to derive the + combined version and content hash. + execution_plan_id: :attr:`~alphalab.backtesting.algorithmic.ExecutionPlan.plan_id` + of the plan whose algorithms worked the run's orders in children + (v4.1.1, ledger FUT-009), or ``None`` for a run that filled them + whole. Rendered into the identity only when present. """ manifest_id: str @@ -417,6 +438,9 @@ class ReproducibilityManifest: engine: EngineIdentity fingerprint: StrategyFingerprint | None build: EngineBuild | None = None + schedule_digest: str | None = None + dataset_parts: tuple[tuple[str, str], ...] = () + execution_plan_id: str | None = None @property def evidence_kind(self) -> str: @@ -471,6 +495,9 @@ def canonical_manifest_key( engine: EngineIdentity, fingerprint: str | None, build: EngineBuild | None = None, + schedule_digest: str | None = None, + dataset_parts: tuple[tuple[str, str], ...] = (), + execution_plan_id: str | None = None, ) -> str: """Render the canonical key a manifest's identity is derived from. @@ -478,7 +505,9 @@ def canonical_manifest_key( a manifest. The configuration enters through its digest; the fingerprint through its own derived identity, which already commits to the code, dependencies, parameters, research configuration and engine it names. The - engine build's two lines are appended only when a build was recorded. + engine build's two lines are appended only when a build was recorded, and + the schedule's line and a combined dataset's part lines only when there are + any (v4.1.1). """ lines = [ @@ -497,6 +526,14 @@ def canonical_manifest_key( if build is not None: lines.append(f"engine.source={build.source_digest!r}") lines.append(f"engine.tz_database={build.tz_database!r}") + if schedule_digest is not None: + lines.append(f"inputs.schedule={schedule_digest!r}") + lines.extend( + f"dataset.part.{index}={version!r},{content!r}" + for index, (version, content) in enumerate(dataset_parts) + ) + if execution_plan_id is not None: + lines.append(f"inputs.execution_plan={execution_plan_id!r}") return "\n".join(lines) @@ -511,6 +548,9 @@ def derive_manifest_id( engine: EngineIdentity, fingerprint: str | None, build: EngineBuild | None = None, + schedule_digest: str | None = None, + dataset_parts: tuple[tuple[str, str], ...] = (), + execution_plan_id: str | None = None, ) -> str: """SHA-256 over :func:`canonical_manifest_key`. Derived, never minted.""" @@ -526,6 +566,9 @@ def derive_manifest_id( engine, fingerprint, build, + schedule_digest, + dataset_parts, + execution_plan_id, ) ) @@ -541,6 +584,9 @@ def _manifest( engine: EngineIdentity, fingerprint: StrategyFingerprint | None, build: EngineBuild | None, + schedule_digest: str | None = None, + dataset_parts: tuple[tuple[str, str], ...] = (), + execution_plan_id: str | None = None, ) -> ReproducibilityManifest: configuration_id = _sha256(configuration) return ReproducibilityManifest( @@ -555,6 +601,9 @@ def _manifest( engine, None if fingerprint is None else fingerprint.fingerprint, build, + schedule_digest, + dataset_parts, + execution_plan_id, ), kind=kind, result_id=result_id, @@ -567,9 +616,52 @@ def _manifest( engine=engine, fingerprint=fingerprint, build=build, + schedule_digest=schedule_digest, + dataset_parts=dataset_parts, + execution_plan_id=execution_plan_id, ) +def _combined_identity(parts: tuple[tuple[str, str], ...]) -> tuple[str, str]: + """The version and content hash a combined dataset derives from its parts. + + The derivation :class:`~alphalab.api.CombinedProvenance` states, restated + here because the lifecycle does not import the facade; a test holds the two + equal. + """ + + versions = "\n".join(version for version, _ in parts) + hashes = "\n".join(content for _, content in parts) + return "combined-" + _sha256(versions), _sha256(hashes) + + +def _parts_of(provenance: DatasetProvenanceView) -> tuple[tuple[str, str], ...]: + """A combined dataset's parts, read from its provenance; empty for one dataset.""" + + parts = getattr(provenance, "parts", ()) + if not ( + isinstance(parts, tuple) + and all( + isinstance(part, tuple) and len(part) == 2 and all(isinstance(x, str) for x in part) + for part in parts + ) + ): + raise LifecycleInputError( + f"Dataset {provenance.dataset_version} states parts that are not " + "(dataset_version, content_hash) pairs, so a manifest could not name them." + ) + if parts and _combined_identity(parts) != ( + provenance.dataset_version, + provenance.content_hash, + ): + raise LifecycleInputError( + f"Dataset {provenance.dataset_version} states parts its version and content " + "hash were not derived from. A manifest naming them would send a rerun to " + "other datasets." + ) + return parts + + def _identifying(dataset: VersionedDataset) -> DatasetProvenanceView: """A dataset's provenance, refused when it records no bytes to reproduce from. @@ -609,6 +701,8 @@ def manifest_for_run( engine: EngineIdentity, *, build: EngineBuild | None, + schedule: RunSchedule | None = None, + execution: ExecutionPlan | None = None, ) -> ReproducibilityManifest: """The manifest of a finished run, with every identity read from its owner. @@ -627,6 +721,16 @@ def manifest_for_run( :func:`~alphalab.lifecycle.fingerprint.running_build` in the same process -- or ``None`` to record none, which is reported as a gap. Required, so that not recording one is a statement. + schedule: The :class:`~alphalab.backtesting.schedule.RunSchedule` the + run was given (v4.1.1, ledger FUT-005), or ``None``. Checked, not + trusted: a run that delivered observations is held to a schedule + holding them, its last delivery among them; recorded by its digest, + which a rerun must be given back. + execution: The :class:`~alphalab.backtesting.algorithmic.ExecutionPlan` + whose algorithms worked the run's orders in children (v4.1.1, ledger + FUT-009), or ``None``. Checked: given exactly when the run routed + ``ALGORITHMIC``, and the plan of the book the result carries; recorded + by its ``plan_id``. Raises: DataValidationError: If ``dataset`` carries no provenance. @@ -634,7 +738,11 @@ def manifest_for_run( payload -- its version then identifies no content -- or if the run names no dataset or a different one, is unseeded, executed no strategy the fingerprint names, or the - fingerprint does not verify. + fingerprint does not verify; if ``schedule`` holds fewer + observations than the run delivered, or not its last delivery; or + if ``execution`` is given for a run that filled its orders whole, is + missing for one that worked them in children, or is not the plan + its book was produced under. """ digest = digest_run(result) @@ -658,6 +766,8 @@ def manifest_for_run( "without a seed would be a claim it cannot keep." ) _require_verified(fingerprint) + _require_schedule_of(result, schedule) + _require_plan_of(result, execution) if fingerprint.strategy_id not in digest.strategy_ids: raise LifecycleInputError( f"The fingerprint is of strategy {fingerprint.strategy_id!r} and the run " @@ -675,9 +785,66 @@ def manifest_for_run( engine=engine, fingerprint=fingerprint, build=build, + schedule_digest=None if schedule is None or not schedule else schedule.digest, + dataset_parts=_parts_of(provenance), + execution_plan_id=None if execution is None else execution.plan_id, ) +def _require_plan_of(result: BacktestResult, execution: ExecutionPlan | None) -> None: + """The plan a manifest names must be the one the run's orders were worked by.""" + + algorithmic = result.config.pipeline.routing is ExecutionRouting.ALGORITHMIC + if execution is None: + if algorithmic: + raise LifecycleInputError( + "The run worked its orders in children and no execution plan was given, so a " + "rerun could not be told which algorithms to work them by." + ) + return + if not algorithmic: + raise LifecycleInputError( + "An execution plan was given for a run that filled its orders whole; a manifest " + "naming it would describe a run that did not happen." + ) + book = result.algorithms if isinstance(result, AlgorithmicResult) else None + if book is None or book.plan.plan_id != execution.plan_id: + raise LifecycleInputError( + f"The run's orders were worked by plan " + f"{'(none recorded)' if book is None else book.plan.plan_id}, not " + f"{execution.plan_id}." + ) + + +def _require_schedule_of(result: BacktestResult, schedule: RunSchedule | None) -> None: + """The schedule a manifest names must be one the run can have been given. + + A run records how many observations it delivered and the last one's + delivery id; a schedule claimed for it must hold that delivery, and hold + observations at all if any were delivered. Nothing about a run says whether + it was given rates or settlements, so those are taken as given. + """ + + if schedule is None: + return + run = result.run + delivered = { + entry.identity for entry in schedule.entries if entry.kind is ScheduleKind.OBSERVATION + } + if run.observations_delivered > len(delivered): + raise LifecycleInputError( + f"The run delivered {run.observations_delivered} observations and the schedule " + f"holds {len(delivered)}: a manifest naming it would claim inputs the run did " + "not read." + ) + last = run.last_observation + if last is not None and last[1] not in delivered: + raise LifecycleInputError( + f"The run's last delivery was {last[1]!r}, which the schedule does not hold; " + "it is not the schedule this run was given." + ) + + def manifest_for_study( result: StudyResult, dataset: VersionedDataset, @@ -770,6 +937,11 @@ def verify_manifest(manifest: ReproducibilityManifest) -> bool: ) if manifest.seed_role is not expected_role: return False + if manifest.dataset_parts and _combined_identity(manifest.dataset_parts) != ( + manifest.dataset_version, + manifest.dataset_content_hash, + ): + return False return manifest.manifest_id == derive_manifest_id( manifest.kind, manifest.result_id, @@ -781,6 +953,9 @@ def verify_manifest(manifest: ReproducibilityManifest) -> bool: manifest.engine, None if manifest.fingerprint is None else manifest.fingerprint.fingerprint, manifest.build, + manifest.schedule_digest, + manifest.dataset_parts, + manifest.execution_plan_id, ) @@ -866,6 +1041,16 @@ def external_requirements(manifest: ReproducibilityManifest) -> tuple[ExternalRe recreatable=True, ) ] + requirements.extend( + ExternalRequirement( + ExternalInput.DATASET_BYTES, + f"part {index + 1} of {len(manifest.dataset_parts)} of {manifest.dataset_version}: " + f"the bytes of {version} (content digest {content}), with the normalization " + "policy it was read under, given back in this order", + recreatable=True, + ) + for index, (version, content) in enumerate(manifest.dataset_parts) + ) fingerprint = manifest.fingerprint if fingerprint is not None: code = fingerprint.code @@ -923,6 +1108,26 @@ def external_requirements(manifest: ReproducibilityManifest) -> tuple[ExternalRe ), ) ) + if manifest.schedule_digest is not None: + requirements.append( + ExternalRequirement( + ExternalInput.AUXILIARY_DATA, + f"the run's schedule with digest {manifest.schedule_digest} -- every " + "observation set, rate, funding, cash flow, split, timer and settlement " + "it was given -- supplied exactly as it was", + recreatable=True, + ) + ) + if manifest.execution_plan_id is not None: + requirements.append( + ExternalRequirement( + ExternalInput.AUXILIARY_DATA, + f"the execution plan {manifest.execution_plan_id} whose algorithms worked " + "the run's orders in children -- each algorithm, window, increment and " + "capability check -- supplied exactly as it was", + recreatable=True, + ) + ) if manifest.mode is ExecutionMode.LIVE: requirements.append( ExternalRequirement( @@ -1049,6 +1254,9 @@ def _input_differences( repr(None if original.fingerprint is None else original.fingerprint.fingerprint), repr(None if rerun.fingerprint is None else rerun.fingerprint.fingerprint), ), + ("schedule", repr(original.schedule_digest), repr(rerun.schedule_digest)), + ("dataset parts", repr(original.dataset_parts), repr(rerun.dataset_parts)), + ("execution plan", repr(original.execution_plan_id), repr(rerun.execution_plan_id)), ): if before != after: differences.append(f"{label}: {before} -> {after}") diff --git a/alphalab/portfolio/__init__.py b/alphalab/portfolio/__init__.py index 2f74b506..3bbefe5e 100644 --- a/alphalab/portfolio/__init__.py +++ b/alphalab/portfolio/__init__.py @@ -18,8 +18,12 @@ from .contracts import contract_exposures as contract_exposures from .contracts import holding_notional as holding_notional from .contracts import settlement_exposures as settlement_exposures +from .corporate_actions import Assignment as Assignment from .corporate_actions import CashFlow as CashFlow from .corporate_actions import CashFlowKind as CashFlowKind +from .corporate_actions import ContractSettlement as ContractSettlement +from .corporate_actions import Exercise as Exercise +from .corporate_actions import OptionExpiry as OptionExpiry from .corporate_actions import Split as Split from .engine import PortfolioEngine as PortfolioEngine from .engine import PortfolioState as PortfolioState @@ -120,6 +124,7 @@ "MULTI_STRATEGY_BOOK_SCHEME", "NO_RATES", "Account", + "Assignment", "BookValuation", "CashDeposited", "CashFlow", @@ -129,8 +134,10 @@ "CashWithdrawn", "ContractExposure", "ContractHolding", + "ContractSettlement", "CurrencyAttribution", "CurrencyAttributionReport", + "Exercise", "ExposureEngine", "ForwardTerms", "FutureDatedRateError", @@ -148,6 +155,7 @@ "MixedCurrencyValuationError", "MultiStrategyBook", "NAVCalculator", + "OptionExpiry", "PnLEngine", "PortfolioEngine", "PortfolioEvent", diff --git a/alphalab/portfolio/corporate_actions.py b/alphalab/portfolio/corporate_actions.py index be9ad999..bc2c7cc9 100644 --- a/alphalab/portfolio/corporate_actions.py +++ b/alphalab/portfolio/corporate_actions.py @@ -27,7 +27,15 @@ from alphalab.portfolio.exceptions import InvalidTransactionError -__all__ = ["CashFlow", "CashFlowKind", "Split"] +__all__ = [ + "Assignment", + "CashFlow", + "CashFlowKind", + "ContractSettlement", + "Exercise", + "OptionExpiry", + "Split", +] @unique @@ -111,3 +119,147 @@ def __post_init__(self) -> None: raise InvalidTransactionError( "A split with a ratio of one changes nothing; it is refused as a likely mistake." ) + + +@dataclass(frozen=True, slots=True) +class ContractSettlement: + """The final cash settlement of a future or an option at its expiry (v4.1.1, FUT-012). + + What an exchange publishes once a contract has stopped trading: the price + every open position in it is closed at. A cash-settled future's last + variation is settled at it; a cash-settled option is exercised at its + intrinsic value per unit -- ``Decimal("0")`` for one expiring worthless. + For a future whose declared + :class:`~alphalab.instrument.terms.FutureTerms` are physical, the deliverable + then moves against this price (v4.1.1, FUT-013). An option whose + :class:`~alphalab.instrument.terms.OptionTerms` are declared is not settled + at a stated price but by :class:`OptionExpiry`, which the engine resolves + from its terms. + + Attributes: + asset_id: The contract. + price: The final settlement price per unit, as the exchange published it. + reference: The caller's reference, not interpreted. + + Raises: + InvalidTransactionError: If the contract is not named or the price is + not a finite Decimal. + """ + + asset_id: str + price: Decimal + reference: str = "" + + def __post_init__(self) -> None: + if not isinstance(self.asset_id, str) or not self.asset_id.strip(): + raise InvalidTransactionError("A settlement names the contract it settles.") + if not isinstance(self.price, Decimal) or not self.price.is_finite(): + raise InvalidTransactionError( + f"A settlement price is a finite Decimal, got {self.price!r}." + ) + + +def _contracts(value: Decimal, what: str) -> None: + if not isinstance(value, Decimal) or not value.is_finite() or value <= 0: + raise InvalidTransactionError(f"{what} is a positive count of contracts, got {value!r}.") + if value != value.to_integral_value(): + raise InvalidTransactionError( + f"{what} is {value}; a contract is exercised or assigned whole." + ) + + +def _settlement_value(value: Decimal | None, required: bool, what: str) -> None: + if value is None: + if required: + raise InvalidTransactionError(f"{what} needs the underlying's settlement value.") + return + if not isinstance(value, Decimal) or not value.is_finite() or value < 0: + raise InvalidTransactionError( + f"{what}'s underlying settlement value is a non-negative finite Decimal, got {value!r}." + ) + + +@dataclass(frozen=True, slots=True) +class Exercise: + """A holder's instruction to exercise ``contracts`` of a long option, effective at its instant. + + The decision is the holder's -- the engine checks the contract allows it then, + and carries out what the contract's + :class:`~alphalab.instrument.terms.OptionTerms` say happens; it does not judge + whether exercising was wise (v4.1.1, FUT-013). + + Attributes: + asset_id: The option. + contracts: How many, whole. + underlying_value: The value the exercise settles against, per unit of + the deliverable, as the venue publishes it -- required for a + cash-settled contract, and ``None`` for a physical one, which needs + no price: the deliverable moves against the strike. + reference: The caller's reference, not interpreted. + """ + + asset_id: str + contracts: Decimal + underlying_value: Decimal | None = None + reference: str = "" + + def __post_init__(self) -> None: + if not isinstance(self.asset_id, str) or not self.asset_id.strip(): + raise InvalidTransactionError("An exercise names the option it exercises.") + _contracts(self.contracts, "An exercise") + _settlement_value(self.underlying_value, False, "An exercise") + + +@dataclass(frozen=True, slots=True) +class Assignment: + """``contracts`` of a short option assigned at its instant (v4.1.1, FUT-013). + + Assignment is the clearing house's notice that holders on the other side + exercised. It is stated, never drawn: which written contracts are assigned + before expiry is not knowable from prices, so an early assignment is an + input a caller supplies -- recorded history, or a policy the researcher + states. At expiry the contract's declared automatic-exercise rule assigns. + + Attributes: + asset_id: The option. + contracts: How many, whole. + underlying_value: As :attr:`Exercise.underlying_value`. + reference: The caller's reference, not interpreted. + """ + + asset_id: str + contracts: Decimal + underlying_value: Decimal | None = None + reference: str = "" + + def __post_init__(self) -> None: + if not isinstance(self.asset_id, str) or not self.asset_id.strip(): + raise InvalidTransactionError("An assignment names the option assigned.") + _contracts(self.contracts, "An assignment") + _settlement_value(self.underlying_value, False, "An assignment") + + +@dataclass(frozen=True, slots=True) +class OptionExpiry: + """An option reaching expiry, with the value it settles against (v4.1.1, FUT-013). + + Every position still open is resolved by the contract's terms: exercised or + assigned where its automatic-exercise rule says so -- in cash at the + intrinsic value, or by delivery against the strike -- and otherwise closed + at nothing, as an expired option is worth. + + Attributes: + asset_id: The option. + underlying_value: The deliverable's settlement value per unit, as the + venue publishes it -- often not the last trade. + reference: The caller's reference, not interpreted. + """ + + asset_id: str + underlying_value: Decimal + reference: str = "" + + def __post_init__(self) -> None: + if not isinstance(self.asset_id, str) or not self.asset_id.strip(): + raise InvalidTransactionError("An expiry names the option expiring.") + _settlement_value(self.underlying_value, True, "An expiry") diff --git a/alphalab/portfolio/engine.py b/alphalab/portfolio/engine.py index 00229bed..a485d83b 100644 --- a/alphalab/portfolio/engine.py +++ b/alphalab/portfolio/engine.py @@ -348,6 +348,7 @@ def apply_fill( currency: str, *, economics: InstrumentEconomics | None = None, + final_settlement: bool = False, ) -> PortfolioState: """Apply one signed fill (BUY > 0, SELL < 0) to the portfolio. @@ -379,7 +380,10 @@ def apply_fill( ``commission`` is signed since v3.11 (ledger ACC-007): a negative one is a rebate, which a venue pays a liquidity provider. And a price need only - be one the economics admit: positive, unless they allow otherwise. + be one the economics admit: positive, unless they allow otherwise -- + or, for a contract's ``final_settlement`` (v4.1.1, FUT-012), zero too: + an option that expires worthless is closed at nothing, which no trade + could have been. Raises: InvalidTransactionError: If the price is not one the economics @@ -402,7 +406,10 @@ def apply_fill( # a fully paid unit of one is recorded as no declaration at all, which # is what it was before v3.11. declared = None if economics is None or economics.is_cash_equity else economics - if not (declared or CASH_EQUITY).admits_price(price): + admitted = (declared or CASH_EQUITY).admits_price(price) or ( + final_settlement and price == 0 + ) + if not admitted: raise InvalidTransactionError( f"A fill of {asset_id} at {price} is refused: its price must be positive, " "unless its economics declare that it may not be (ACC-007)." diff --git a/alphalab/reinforcement_learning/__init__.py b/alphalab/reinforcement_learning/__init__.py index abcc566e..5c44d7d4 100644 --- a/alphalab/reinforcement_learning/__init__.py +++ b/alphalab/reinforcement_learning/__init__.py @@ -7,8 +7,10 @@ Building this environment surfaced a real, previously-unreachable bug in `alphalab.portfolio.engine.PortfolioEngine.apply_fill` (double-counted realized -P&L on closing trades) -- see `alphalab.reinforcement_learning.environment`'s module -docstring for the full explanation. Not fixed here; flagged clearly instead. +P&L on closing trades). It was fixed upstream in v2.0.0 (CHANGELOG, D1) and is +pinned by `tests/regression/test_close_fill_cash_accounting.py`; see +`alphalab.reinforcement_learning.environment`'s module docstring. Until v4.1.1 +this docstring still called it unfixed. """ from alphalab.reinforcement_learning.action import Action, action_to_signed_quantity diff --git a/alphalab/reinforcement_learning/environment.py b/alphalab/reinforcement_learning/environment.py index e9d65d81..b7a8517e 100644 --- a/alphalab/reinforcement_learning/environment.py +++ b/alphalab/reinforcement_learning/environment.py @@ -10,28 +10,23 @@ registered the same way any other strategy would be -- not a bypass of the strategy layer, a real (if minimal) participant in it. -One real gap, handled explicitly rather than silently: the production pipeline only -marks a position to market when it fills, not on every price tick. A trading -environment's reward signal needs unrealized P&L to reflect every price move, held -position or not, so `step_environment` marks the traded asset's position to the -current price after every step before computing equity. - -KNOWN BUG IN THE UNDERLYING PIPELINE, DISCOVERED WHILE BUILDING THIS ENVIRONMENT, -NOT FIXED HERE: `alphalab.portfolio.engine.PortfolioEngine.apply_fill` computes -`cash_impact = -(quantity * price) - commission + pnl`. The `pnl` term double-counts -realized P&L -- it is already fully reflected in `-(quantity * price)`, since `pnl` -is derived by comparing the fill price to the position's average cost, not a -separate cash flow. This only manifests on a closing/reducing trade with nonzero -realized P&L, which is why no earlier PR in this project caught it: options, -futures, and crypto all constructed `Position` directly via their bridge helpers -and never routed a round-trip (open then close) trade through the full -`ExecutionPipeline`. A BUY-then-HOLD-then-SELL sequence in this module's own tests -reproduces it exactly. Not fixed in this PR -- `portfolio/engine.py` is core -accounting code used throughout the framework, and a change there deserves review -on its own, not as a side effect of an RL environment. This environment's rewards -are internally consistent (sum of step rewards equals the total equity change -reported by the same buggy function) but will not match a correct hand calculation -of realized P&L on closing trades until that bug is fixed upstream. +Two notes from when this environment was built, kept as history: + +* The pipeline at the time marked a position to market only when it filled. It + has marked every position on every price event since v2.1 + (`tests/integration/test_mark_to_market_pipeline.py`); `step_environment` still + marks the traded asset at the step's price before computing equity, which is + now the same figure the pipeline holds, and harmless. +* Building it surfaced a defect in + `alphalab.portfolio.engine.PortfolioEngine.apply_fill`, which computed + `cash_impact = -(quantity * price) - commission + pnl` and so double-counted + realized P&L into cash on a closing or reducing fill. **It was fixed in v2.0.0** + (CHANGELOG, D1): the `+ pnl` term is gone, and + `tests/regression/test_close_fill_cash_accounting.py` drives the real pipeline + open -> hold -> close. This environment's rewards therefore match a hand + calculation -- `test_environment_round_trip_realizes_correct_pnl` asserts the + correct +20 on a buy at 150 and a sale at 152. Until v4.1.1 this docstring still + described the defect as present. """ from collections.abc import Mapping diff --git a/alphalab/runtime/__init__.py b/alphalab/runtime/__init__.py index c071d2d6..99a742cc 100644 --- a/alphalab/runtime/__init__.py +++ b/alphalab/runtime/__init__.py @@ -58,7 +58,7 @@ route_order, venue_order_type, ) -from alphalab.runtime.calendars import VenueCalendars +from alphalab.runtime.calendars import VenueCalendars, session_close, settles_at from alphalab.runtime.checkpoint import ( CHECKPOINT_SCHEMA, CheckpointMark, @@ -150,6 +150,8 @@ "restore_checkpoints", "route_child_order", "route_order", + "session_close", + "settles_at", "venue_order_type", "wants_slices", ] diff --git a/alphalab/runtime/assumptions.py b/alphalab/runtime/assumptions.py index f1b6caea..8dcc83b5 100644 --- a/alphalab/runtime/assumptions.py +++ b/alphalab/runtime/assumptions.py @@ -94,7 +94,8 @@ class ExecutionAssumptions: Attributes: routing: Where accepted orders executed. Under ``EXTERNAL`` routing nothing was simulated, and the fields below describe a model no - order went through. + order went through. Under ``ALGORITHMIC`` routing (v4.1.1) each + order's children were simulated by them. fill_timing: When a simulated order filled. fill_policy: The fill policy, described. costs: The cost model every simulated fill was priced by, described -- @@ -117,9 +118,9 @@ class ExecutionAssumptions: @property def simulated(self) -> bool: - """Whether a simulator executed the run's orders.""" + """Whether a simulator executed the run's orders, whole or in children.""" - return self.routing.name == "SIMULATED" + return self.routing.name in ("SIMULATED", "ALGORITHMIC") @property def optimistic(self) -> tuple[str, ...]: diff --git a/alphalab/runtime/calendars.py b/alphalab/runtime/calendars.py index 748c6faa..3a998a3f 100644 --- a/alphalab/runtime/calendars.py +++ b/alphalab/runtime/calendars.py @@ -47,6 +47,8 @@ from types import MappingProxyType from typing import Any +from alphalab.conventions.exceptions import ConventionInputError, ConventionViolationError +from alphalab.conventions.settlement import SettlementRule, settlement_date from alphalab.data.calendar import MarketCalendar, SessionWindow from alphalab.data.exceptions import DataValidationError from alphalab.instrument.exceptions import InstrumentInputError @@ -55,7 +57,7 @@ from alphalab.persistence.exceptions import StateDecodeError from alphalab.runtime.exceptions import RuntimeValidationError -__all__ = ["VenueCalendars", "venue_calendars_from_primitives"] +__all__ = ["VenueCalendars", "session_close", "settles_at", "venue_calendars_from_primitives"] @dataclass(frozen=True, slots=True) @@ -250,3 +252,60 @@ def venue_calendars_from_primitives(value: Any, where: str) -> VenueCalendars: ) except RuntimeValidationError as exc: raise StateDecodeError(f"{where} could not be declared: {exc}") from exc + + +# --------------------------------------------------------------------------- # +# A contract's lifecycle instants, from a market's own calendar (FUT-013) +# --------------------------------------------------------------------------- # + + +def session_close(calendar: MarketCalendar, day: date) -> float: + """The instant ``calendar``'s market closes on the local date ``day``. + + The last close of the day -- after a lunch break, the afternoon's. What an + expiry "at the close" on a local date is, in Unix seconds, wherever the + market is and whatever its daylight-saving rules: the calendar's time zone + decides, not the caller. + + Raises: + RuntimeValidationError: If the market does not trade on ``day``. Which + way a contract's date moves when its market is shut is the + contract's convention, and it is not chosen here. + """ + + bounds = calendar.session_bounds(day) + if bounds is None: + raise RuntimeValidationError( + f"{calendar.calendar_id} does not trade on {day.isoformat()}; whether a date falling " + "on a closed day moves earlier or later is the contract's convention, not a default." + ) + return bounds[1] + + +def settles_at(calendar: MarketCalendar, rule: SettlementRule, trade_day: date) -> float: + """The instant a lifecycle event struck on ``trade_day`` settles, by ``rule``. + + The settlement date is :func:`~alphalab.conventions.settlement.settlement_date` + over ``calendar`` -- the conventions package's one authority on T+n -- and + the instant is that date's first session open in the market's own time zone, + when cash and the deliverable are treated as having moved. + + Raises: + RuntimeValidationError: If the settlement date is one the market does not + trade on -- a calendar-day rule landing on a weekend -- since nothing + here decides when on a closed day a delivery happens; or the rule + cannot be resolved over the calendar. + """ + + try: + settles = settlement_date(rule, trade_day, calendar if rule.needs_calendar else None) + except (ConventionInputError, ConventionViolationError) as exc: + raise RuntimeValidationError(f"The settlement date cannot be resolved: {exc}") from exc + bounds = calendar.session_bounds(settles) + if bounds is None: + raise RuntimeValidationError( + f"{rule.label} from {trade_day.isoformat()} settles on {settles.isoformat()}, which " + f"{calendar.calendar_id} does not trade on; when on a closed day a delivery happens " + "is not assumed." + ) + return bounds[0] diff --git a/alphalab/runtime/context_views.py b/alphalab/runtime/context_views.py index 01904c75..ebc5c753 100644 --- a/alphalab/runtime/context_views.py +++ b/alphalab/runtime/context_views.py @@ -412,7 +412,27 @@ def tick(self, asset_id: str) -> Tick | None: return self._market.latest_ticks.get(asset_id) def bar(self, asset_id: str) -> Bar | None: - return self._market.latest_bars.get(asset_id) + """The latest bar published for ``asset_id``, of whatever interval, or ``None``. + + The market engine keeps one latest bar per asset *and interval*, keyed + ``"_"`` (:func:`alphalab.market.views.latest_bar` + reads one by both). Until v4.1.1 this looked the bare ``asset_id`` up in + that index, which no key ever equals, so it returned ``None`` for every + asset in every run since v2.10 -- a strategy reading its context's bars + saw none, and no test read one. It now finds the asset's bars by their + key and returns the most recent; of two closing at the same instant, the + one covering the longer interval, which contains the other. Linear in the + bars the market holds, as :attr:`prices` is in the prices. + """ + + prefix = f"{asset_id}_" + latest: Bar | None = None + for key, bar in self._market.latest_bars.items(): + if not key.startswith(prefix) or bar.asset_id != asset_id: + continue + if latest is None or _bar_order(bar) > _bar_order(latest): + latest = bar + return latest def price(self, asset_id: str) -> Decimal | None: """The price the run is marking this asset at, or ``None`` if unpriced. @@ -436,6 +456,13 @@ def assets(self) -> Sequence[str]: return tuple(sorted(self._prices)) +def _bar_order(bar: Bar) -> tuple[float, float]: + """Latest first, then the longer interval; a month-counted interval is the longest.""" + + seconds = bar.timeframe.seconds + return (bar.timestamp, float("inf") if seconds is None else seconds) + + # --------------------------------------------------------------------------- # History # --------------------------------------------------------------------------- diff --git a/alphalab/runtime/execution_pipeline.py b/alphalab/runtime/execution_pipeline.py index 4a19c37e..b03d7ba3 100644 --- a/alphalab/runtime/execution_pipeline.py +++ b/alphalab/runtime/execution_pipeline.py @@ -9,7 +9,7 @@ import decimal from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from decimal import Decimal from enum import Enum, auto from types import MappingProxyType @@ -30,14 +30,17 @@ from alphalab.common.ids import IdStreamPosition, current_id_position from alphalab.common.order_terms import OrderTerms, TimeInForce from alphalab.common.persistent_map import PersistentMap -from alphalab.conventions.economics import InstrumentEconomics +from alphalab.conventions.economics import InstrumentEconomics, SettlementModel from alphalab.conventions.lot import LotSpecification from alphalab.core.contribution import StrategyContribution, split_by_contribution +from alphalab.core.enums import AssetType from alphalab.core.enums import Side as CoreSide from alphalab.core.fill import Fill as CoreFill from alphalab.core.order_request import OrderRequest from alphalab.core.trade import Trade as CoreTrade from alphalab.data.calendar import MAX_SESSION_SEARCH_DAYS +from alphalab.execution.algorithms import ChildOrder +from alphalab.execution.algorithms import _apportion as _largest_remainder from alphalab.execution.engine import ExecutionEngine from alphalab.execution.fill import FillStatus, OrderInstruction from alphalab.execution.policy import ( @@ -52,7 +55,9 @@ from alphalab.execution.state import ExecutionState from alphalab.instrument.economics import economics_for from alphalab.instrument.exceptions import InstrumentInputError +from alphalab.instrument.record import InstrumentRecord from alphalab.instrument.registry import InstrumentRegistry +from alphalab.instrument.terms import Deliverable, FutureTerms, OptionTerms from alphalab.market.bar import Bar, IntervalUnit, TimeFrame from alphalab.market.engine import MarketEngine from alphalab.market.events import ( @@ -73,9 +78,18 @@ from alphalab.oms.state import OMSState from alphalab.oms.status import OrderStatus, OrderType from alphalab.oms.status import Side as OMSSide +from alphalab.options.enums import OptionType +from alphalab.options.expiration import SettlementStyle from alphalab.portfolio.account import Account from alphalab.portfolio.book import BookGroups, BookMarketValues, PositionBook -from alphalab.portfolio.corporate_actions import CashFlow, Split +from alphalab.portfolio.corporate_actions import ( + Assignment, + CashFlow, + ContractSettlement, + Exercise, + OptionExpiry, + Split, +) from alphalab.portfolio.engine import PortfolioEngine, PortfolioState from alphalab.portfolio.events import PortfolioEvent, PositionClosed, PositionReduced from alphalab.portfolio.fx import NO_RATES, FxConversion, FxRates @@ -162,6 +176,17 @@ class ExecutionRouting(Enum): #: back later through :mod:`alphalab.runtime.broker_routing`. Live only. EXTERNAL = auto() + #: The order is the parent an execution algorithm works in children, in a + #: historical run (v4.1.1, ledger FUT-009). It is accepted and left working, + #: holding its reservation and its contributions, and the pipeline never + #: simulates it whole: each child is simulated by + #: :meth:`ExecutionPipeline.work_child` against the run's own fill policy, + #: simulator and cost model, and its fill settles on the parent. Terms an + #: algorithm cannot work -- a stop, an auction, an immediate order -- are + #: refused before an order exists. Backtest and replay only; see + #: :mod:`alphalab.backtesting.algorithmic`. + ALGORITHMIC = auto() + @dataclass(frozen=True, slots=True) class ExecutionPipelineConfig: @@ -395,6 +420,31 @@ class SettlementRefusal: timestamp: float +@dataclass(frozen=True, slots=True) +class ChildTurn: + """What one execution-algorithm child did at one market event (v4.1.1, ledger FUT-009). + + Attributes: + state: The pipeline after the child's fill, if any, settled on its parent. + reports: The execution reports the fill produced -- each against the + **parent** order, as a venue fill of a routed child is. + fills: The canonical fills. + trades: The canonical trades. + filled: What the child filled at this event. + outcome: ``None`` while the child works on; ``FILLED`` once it has filled + in full; ``CANCELLED`` for a market child's unfilled remainder, which + is withdrawn after its one attempt, as a market order's is; + ``REJECTED`` or ``EXPIRED`` when the fill policy decided so. + """ + + state: ExecutionPipelineState + reports: tuple[ExecutionReport, ...] + fills: tuple[CoreFill, ...] + trades: tuple[CoreTrade, ...] + filled: Decimal + outcome: OrderStatus | None + + @dataclass(frozen=True, slots=True) class ExecutionPipelineState: """Immutable snapshot of all subsystems in the execution path.""" @@ -1286,6 +1336,127 @@ def apply_terminal_outcome( released = _release_if_terminal(terminated, order.order_id, timestamp) return evolve(released, id_position=current_id_position()) + @staticmethod + @in_accounting_context + def work_child( + state: ExecutionPipelineState, + child: ChildOrder, + working: Decimal, + event: MarketEvent, + policy: FillPolicy, + rates: FxRates = NO_RATES, + *, + arriving: bool, + ) -> ChildTurn: + """One execution-algorithm child's turn at ``event``, its fill settled on the parent. + + The historical counterpart of routing a child to a venue (v4.1.1, ledger + FUT-009). The parent is the OMS order the algorithm works under + :attr:`ExecutionRouting.ALGORITHMIC`; the child is not an order of its + own. The **fill policy** decides what the child's ``working`` quantity + gets from the liquidity ``event`` showed -- so a liquidity cap applies to + each child -- and the **simulator** prices it by the run's cost model. + The fill is then applied to the parent by the path every simulated and + venue fill takes: the OMS records a partial fill (or the fill that + completes the parent), the portfolio books it, the allocation + reservation is consumed, and the trade record carries the parent's + contributions, so attribution splits by the strategies that asked. + + The child's terms are the order's: a ``MARKET`` child has one attempt at + the event's price and what it does not fill is withdrawn (``CANCELLED``); + a ``LIMIT`` child meets the market as a taker on its first turn + (``arriving``) when the cost model prices taking it within its limit, + and otherwise rests and fills at its limit, as a maker, when the market + trades through it -- the rules :func:`_work_order` applies to an order. + Nothing here reads a later event: ``event`` is the one the child is + worked at, and the caller decides which events a child may see. + + Raises: + RuntimeValidationError: If the run does not route algorithmically; + ``event`` is not of the child's asset or carries no price; the + parent is unknown, finished, or not the child's; or ``working`` + is not positive or exceeds what the parent has left unfilled -- + the over-execution the parent's quantity forbids. + """ + + if state.config.routing is not ExecutionRouting.ALGORITHMIC: + raise RuntimeValidationError( + f"Children are worked by the pipeline only under ALGORITHMIC routing; this run " + f"routes {state.config.routing.name}." + ) + priced = _market_price(event) + if priced is None or priced[0] != child.asset_id: + raise RuntimeValidationError( + f"Child {child.child_id} trades {child.asset_id}; the event prices " + f"{'nothing' if priced is None else priced[0]}." + ) + parent_id = OrderId(UUID(child.parent_order_id)) + if not state.oms.orders.contains(parent_id): + raise RuntimeValidationError(f"Child {child.child_id} names an unknown parent.") + parent = state.oms.orders.find(parent_id) + if not parent.is_open or parent.asset_id != child.asset_id or parent.side != child.side: + raise RuntimeValidationError( + f"Child {child.child_id} cannot work parent {child.parent_order_id}: it is " + f"{parent.status} on {parent.asset_id} {parent.side}." + ) + if working <= Decimal("0") or working > parent.remaining_quantity: + raise RuntimeValidationError( + f"Child {child.child_id} would work {working} with parent " + f"{child.parent_order_id} {parent.remaining_quantity} short of filled." + ) + + # The child as the policy and the cost model see it: the parent's + # identity, side and asset, the child's quantity, order type and limit. + view = replace( + parent, + order_type=child.order_type, + limit_price=child.limit_price, + remaining_quantity=working, + time_in_force=TimeInForce.GTC, + ) + market = priced[1] + price: Decimal | None = market + passive = False + if child.order_type is OrderType.LIMIT: + if arriving and _within_limit(view, _taker_price(state, view, event, market)): + price = market + else: + price = _resting_fill_price(view, event) + passive = True + if price is None: + return ChildTurn(state, (), (), (), Decimal("0"), None) + + decision = _decide_fill(policy, view, event, price, _whole_units(state)) + if decision.status is FillStatus.REJECTED: + return ChildTurn(state, (), (), (), Decimal("0"), OrderStatus.REJECTED) + if decision.status is FillStatus.EXPIRED: + return ChildTurn(state, (), (), (), Decimal("0"), OrderStatus.EXPIRED) + unfilled = OrderStatus.CANCELLED if child.order_type is OrderType.MARKET else None + if decision.status is FillStatus.NO_FILL: + return ChildTurn(state, (), (), (), Decimal("0"), unfilled) + + quantity = working if decision.quantity is None else min(decision.quantity, working) + # Status against the parent, as a venue fill's is: a fill completes the + # parent only when it leaves nothing of the parent unfilled. + status = ( + FillStatus.FULL_FILL + if quantity >= parent.remaining_quantity + else FillStatus.PARTIAL_FILL + ) + current, reports = _execute_order( + state, parent, FillDecision(status, quantity), event, price=price, passive=passive + ) + current, fills, trades = _apply_reports(current, parent, reports, rates) + outcome = OrderStatus.FILLED if quantity == working else unfilled + return ChildTurn( + evolve(current, id_position=current_id_position()), + reports, + fills, + trades, + quantity, + outcome, + ) + @staticmethod @in_accounting_context def process_timer( @@ -1577,6 +1748,359 @@ def apply_split( ) return _after_book_change(current, portfolio, timestamp, rates) + @staticmethod + @in_accounting_context + def settle_contract( + state: ExecutionPipelineState, + settlement: ContractSettlement, + timestamp: float, + rates: FxRates = NO_RATES, + ) -> ExecutionPipelineState: + """Close every position in an expired contract at its final settlement price (FUT-012). + + The contract must be one the run's registry declares, with a last + trading instant at or before ``timestamp``, and a future or an option + -- what settles at expiry -- or a bond, redeemed at maturity at the + price ``settlement`` states (v4.1.1, FUT-010). Then, as at a split, an order still working + in it is cancelled and what it held freed (refused under external + routing, where the venue reports what became of its orders); the + position is closed at the settlement price with no commission, by the + portfolio's one fill authority, so a future realizes its last variation + and an option pays its intrinsic value times its multiplier; each + strategy's share closes with it; a trade record attributes the realized + P&L to the strategies that held it; the run is marked at the price; and + risk is resynced and an equity point recorded. + + Settling a contract the run holds no position in only marks it. + + Raises: + RuntimeValidationError: If ``timestamp`` is before the last event + the pipeline processed; the contract is not declared, declares + no last trading instant, has not expired by ``timestamp``, or is + not a future or an option; the price is one its economics do not + admit; or orders are working in it under external routing. + """ + + _require_not_before_last_event(state, timestamp, "A contract settlement") + asset_id = settlement.asset_id + registry = state.config.instruments + record = None if registry is None else registry.record_for(asset_id) + if record is None: + raise RuntimeValidationError( + f"{asset_id} is not declared in this run's instrument registry, so nothing " + "says it expires or how it settles; a settlement for it is refused." + ) + named = f"{record.symbol} on {record.exchange}" + if record.last_trade_at is None: + raise RuntimeValidationError( + f"{named} declares no last trading instant: it does not expire, so it is not " + "settled. Declare InstrumentRecord.last_trade_at for a contract that expires." + ) + if timestamp < record.last_trade_at: + raise RuntimeValidationError( + f"{named} trades until {record.last_trade_at!r}; a settlement at {timestamp!r} " + "would close positions while the contract still trades." + ) + economics = economics_for(record.asset_type, record.economics, named) + redeemed = record.asset_type is AssetType.BOND + if economics.settlement not in _EXPIRING and not redeemed: + raise RuntimeValidationError( + f"{named} settles as {economics.settlement.value}: only a future or an option " + "is settled at expiry, and a bond redeemed at maturity." + ) + price = settlement.price + if price < 0 and not economics.allows_negative_prices: + raise RuntimeValidationError( + f"{named} is settled at {price}, which its economics do not admit." + ) + if isinstance(record.terms, OptionTerms): + raise RuntimeValidationError( + f"{named} declares its option terms, so its expiry is resolved from them: give " + "an OptionExpiry with the underlying's settlement value, not a ContractSettlement " + "at a price the caller computed." + ) + delivery = ( + record.terms.deliverable + if isinstance(record.terms, FutureTerms) + and record.terms.settlement is SettlementStyle.PHYSICAL + else None + ) + + working = state.oms.working_orders_for(asset_id) + current = state + if working: + if state.config.routing is ExecutionRouting.EXTERNAL: + raise RuntimeValidationError( + f"{len(working)} order(s) are working in {named} at the venue. The venue " + "reports what became of them at expiry; settle the contract once they are " + "resolved." + ) + for order_id in working: + current = _end_unfilled(current, current.oms.orders.find(order_id), timestamp) + + portfolio = current.portfolio + position = portfolio.positions.get(asset_id) + signed_shares = AllocationEngine.shares_of(current.allocation, asset_id) + if delivery is not None and position is not None and position.quantity != 0: + # Checked before anything moves: a delivery the book cannot take is + # refused whole, not half-applied. + _require_deliverable( + current, + record, + delivery, + delivery.units * position.quantity, + price, + economics.multiplier, + ) + allocation, shares = AllocationEngine.settle_asset(current.allocation, asset_id) + current = evolve( + current, + allocation=allocation, + market_prices=_market_prices_with_event(current.market_prices, (asset_id, price)), + ) + record_of_settlement: TradeRecord | None = None + if position is not None and position.quantity != 0: + before = len(portfolio.events) + opened_at = position.opened_at + portfolio = PortfolioEngine.apply_fill( + portfolio, + asset_id, + -position.quantity, + price, + Decimal("0"), + timestamp, + position.currency, + economics=economics, + final_settlement=True, + ) + realized = Decimal("0.00") + for event in portfolio.events[before:]: + if isinstance(event, PositionReduced | PositionClosed): + realized = event.realized_pnl + break + record_of_settlement = TradeRecord( + # Derived, not drawn from the run's identifier stream. + trade_id=f"SETTLEMENT-{asset_id}-{timestamp!r}", + asset_id=asset_id, + sector_id=_sector_for(current, asset_id), + realized_pnl=realized, + notional_value=ACCOUNTING_CONTEXT.multiply(position.quantity.copy_abs(), price), + holding_period_seconds=None if opened_at is None else timestamp - opened_at, + contributions=shares, + ) + if record_of_settlement is not None: + current = evolve( + current, trade_records=current.trade_records.append(record_of_settlement) + ) + if delivery is not None and position is not None and position.quantity != 0: + # A physically settled future: the long takes the deliverable and + # pays the final price for it, the short delivers and receives it. + current = _deliver( + evolve(current, portfolio=portfolio), + record, + delivery, + {sid: delivery.units * held for sid, held in signed_shares}, + delivery.units * position.quantity, + price, + economics.multiplier, + timestamp, + rates, + f"DELIVERY-{asset_id}-{timestamp!r}", + ) + portfolio = current.portfolio + return _after_book_change(current, portfolio, timestamp, rates) + + @staticmethod + @in_accounting_context + def exercise( + state: ExecutionPipelineState, + exercise: Exercise, + timestamp: float, + rates: FxRates = NO_RATES, + ) -> ExecutionPipelineState: + """Exercise ``exercise.contracts`` of a long option at ``timestamp`` (v4.1.1, FUT-013). + + The option's :class:`~alphalab.instrument.terms.OptionTerms` decide what + happens: inside its exercise window -- any instant to expiry for an + American contract, from the last trading instant to expiry for a European + one -- a cash-settled contract pays its intrinsic value against the + settlement value the instruction carries, and a physically settled one + delivers its deliverable against the strike: a call takes it and pays + ``strike x multiplier`` a contract, a put delivers it and receives that. + Every movement goes through the portfolio's one fill authority, the + exercised contracts closed at what they pay (nothing, when they deliver), + and each strategy's share of the position closes and receives its share + of the delivery. The engine carries out the instruction; whether + exercising was wise is the holder's question. + + Raises: + RuntimeValidationError: If ``timestamp`` is before the last event; + the option is undeclared or declares no terms; the instant is + outside its exercise window; the book is not long that many + contracts; orders work in it; a cash-settled exercise carries no + settlement value or a physical one carries one; or the delivery + cannot be taken -- see :meth:`expire_option`. + """ + + record, terms, economics, named = _declared_option(state, exercise.asset_id, timestamp) + _require_in_window(record, terms, timestamp, named, "exercised") + _require_no_working_orders(state, exercise.asset_id, named, "exercised") + held = _held(state, exercise.asset_id) + if held < exercise.contracts: + raise RuntimeValidationError( + f"{exercise.contracts} contracts of {named} cannot be exercised: the book is " + f"{held} of them, and only a long position is exercised." + ) + return _resolve_option( + state, + record, + terms, + economics, + exercise.contracts, + exercise.underlying_value, + timestamp, + rates, + f"EXERCISE-{exercise.asset_id}-{timestamp!r}-{exercise.reference}", + ) + + @staticmethod + @in_accounting_context + def assign( + state: ExecutionPipelineState, + assignment: Assignment, + timestamp: float, + rates: FxRates = NO_RATES, + ) -> ExecutionPipelineState: + """Assign ``assignment.contracts`` of a short option at ``timestamp`` (v4.1.1, FUT-013). + + The mirror of :meth:`exercise`, from the writer's side: a cash-settled + contract pays out its intrinsic value; a physically settled call + delivers the deliverable and receives the strike, a put takes it and + pays the strike. Assignment is an input, never drawn -- see + :class:`~alphalab.portfolio.corporate_actions.Assignment`. + + Raises: + RuntimeValidationError: As :meth:`exercise`, with "short" for "long". + """ + + record, terms, economics, named = _declared_option(state, assignment.asset_id, timestamp) + _require_in_window(record, terms, timestamp, named, "assigned") + _require_no_working_orders(state, assignment.asset_id, named, "assigned") + held = _held(state, assignment.asset_id) + if -held < assignment.contracts: + raise RuntimeValidationError( + f"{assignment.contracts} contracts of {named} cannot be assigned: the book is " + f"{held} of them, and only a short position is assigned." + ) + return _resolve_option( + state, + record, + terms, + economics, + -assignment.contracts, + assignment.underlying_value, + timestamp, + rates, + f"ASSIGNMENT-{assignment.asset_id}-{timestamp!r}-{assignment.reference}", + ) + + @staticmethod + @in_accounting_context + def expire_option( + state: ExecutionPipelineState, + expiry: OptionExpiry, + timestamp: float, + rates: FxRates = NO_RATES, + ) -> ExecutionPipelineState: + """Resolve every open position in an option at its expiry (v4.1.1, FUT-013). + + At or after the contract's ``expires_at``, against the underlying's + settlement value: orders still working in it are cancelled, as at a + settlement; then, if its automatic-exercise rule exercises at that value, + a long position is exercised and a short one assigned -- in cash or by + delivery, by its terms -- and otherwise the position is closed at + nothing. A contract with no rule exercises nothing it was not instructed + to. The option is marked at its intrinsic value. + + A delivery the book cannot take is refused rather than booked: a + deliverable the registry does not declare, or one priced in another + currency than the strike; a delivery leaving the book short when the + run does not allow shorting; or a payment leaving the cash it is paid + in below zero -- no financing of a delivery is modelled. + + Raises: + RuntimeValidationError: If ``timestamp`` is before the last event or + before the contract expires; the option is undeclared or declares + no terms; orders work in it under external routing; or the + delivery cannot be taken. + """ + + record, terms, economics, named = _declared_option(state, expiry.asset_id, timestamp) + if timestamp < terms.expires_at: + raise RuntimeValidationError( + f"{named} expires at {terms.expires_at!r}; an expiry at {timestamp!r} would " + "resolve it while it can still be exercised." + ) + current = state + working = state.oms.working_orders_for(expiry.asset_id) + if working: + if state.config.routing is ExecutionRouting.EXTERNAL: + raise RuntimeValidationError( + f"{len(working)} order(s) are working in {named} at the venue; resolve them " + "before its expiry is." + ) + for order_id in working: + current = _end_unfilled(current, current.oms.orders.find(order_id), timestamp) + intrinsic = terms.intrinsic( + expiry.underlying_value, + economics.multiplier, + _deliverable_multiplier(state, record, terms.deliverable), + ) + current = evolve( + current, + market_prices=_market_prices_with_event( + current.market_prices, (expiry.asset_id, intrinsic) + ), + ) + held = _held(current, expiry.asset_id) + if held == 0: + return current + rule = terms.automatic_exercise + if rule is not None and rule.exercises(intrinsic): + return _resolve_option( + current, + record, + terms, + economics, + held, + expiry.underlying_value if terms.settlement is SettlementStyle.CASH else None, + timestamp, + rates, + f"EXPIRY-{expiry.asset_id}-{timestamp!r}", + ) + # Not exercised: the contract expires and is worth nothing. + shares = _strategy_parts( + AllocationEngine.shares_of(current.allocation, expiry.asset_id), held + ) + current = evolve( + current, + allocation=AllocationEngine.apply_lifecycle( + current.allocation, expiry.asset_id, shares + ), + ) + current = _lifecycle_fill( + current, + expiry.asset_id, + -held, + Decimal("0"), + timestamp, + record.currency, + economics, + shares, + f"EXPIRY-{expiry.asset_id}-{timestamp!r}", + ) + return _after_book_change(current, current.portfolio, timestamp, rates) + @staticmethod @in_accounting_context def compile_analytics( @@ -1735,10 +2259,11 @@ def _route_requests( current = _retire_dropped_request(current, request.order_id, instant) continue current, order = _submit_and_accept_order(current, request, instant) - if current.config.routing is ExecutionRouting.EXTERNAL: - # The order is now working and belongs to whoever routes it. No - # fill is invented, the order is not closed out, and its - # reservation stays held -- the capital is still committed. + if current.config.routing is not ExecutionRouting.SIMULATED: + # The order is now working and belongs to whoever routes it -- the + # venue, or the execution algorithm working it in children. No fill + # is invented, the order is not closed out, and its reservation + # stays held -- the capital is still committed. orders.append(order) continue if rest or current.config.fill_timing is FillTiming.NEXT_EVENT: @@ -1882,6 +2407,416 @@ def _instrument_grid( return lots, minimums, multipliers +def _expired_at(state: ExecutionPipelineState, asset_id: str) -> float | None: + """The last trading instant ``asset_id``'s record declares, or ``None`` (FUT-012). + + Read from the registry's index of expiring instruments, so a run whose + registry declares none -- every run before v4.1.1 -- pays one emptiness test. + """ + + registry = state.config.instruments + if registry is None or not registry.expiring: + return None + return registry.expiring.get(asset_id) + + +def held_past_expiry(state: ExecutionPipelineState, at: float) -> tuple[str, ...]: + """Every contract the book still holds whose last trading instant is before ``at`` (FUT-012). + + What a :class:`~alphalab.portfolio.corporate_actions.ContractSettlement` + should have closed. Sorted, so the answer does not depend on the order the + positions were opened in. + """ + + return tuple( + sorted( + asset_id + for asset_id, position in state.portfolio.positions.items() + if position.quantity != 0 + and (expired := _expired_at(state, asset_id)) is not None + and expired < at + ) + ) + + +def _declared(state: ExecutionPipelineState, asset_id: str) -> InstrumentRecord | None: + """An instrument's declaration, for a lifecycle event: a contract or its deliverable. + + The one keyed read a lifecycle event takes, once per contract or deliverable + it names -- never per record. + """ + + registry = state.config.instruments + return None if registry is None else registry.record_for(asset_id) + + +def _declared_option( + state: ExecutionPipelineState, asset_id: str, timestamp: float +) -> tuple[InstrumentRecord, OptionTerms, InstrumentEconomics, str]: + """The option's record, terms and economics, or a refusal naming what is missing.""" + + _require_not_before_last_event(state, timestamp, "An option's exercise, assignment or expiry") + record = _declared(state, asset_id) + if record is None: + raise RuntimeValidationError( + f"{asset_id} is not declared in this run's instrument registry, so nothing says " + "what exercising it does." + ) + named = f"{record.symbol} on {record.exchange}" + terms = record.terms + if not isinstance(terms, OptionTerms): + raise RuntimeValidationError( + f"{named} declares no OptionTerms, so nothing says what exercising it delivers or " + "pays. Declare its terms, or settle it with a ContractSettlement at a stated price." + ) + economics = economics_for(record.asset_type, record.economics, named) + return record, terms, economics, named + + +def _require_in_window( + record: InstrumentRecord, terms: OptionTerms, timestamp: float, named: str, done: str +) -> None: + assert record.last_trade_at is not None # terms are declared only with one + start, end = terms.exercise_window(record.last_trade_at) + if not start <= timestamp <= end: + raise RuntimeValidationError( + f"{named} is {terms.exercise_style.name}: it may be {done} from {start!r} to " + f"{end!r}, and not at {timestamp!r}." + ) + + +def _require_no_working_orders( + state: ExecutionPipelineState, asset_id: str, named: str, done: str +) -> None: + working = state.oms.working_orders_for(asset_id) + if working: + raise RuntimeValidationError( + f"{len(working)} order(s) are working in {named}; contracts are not {done} while " + "orders may still trade the position. Resolve them first." + ) + + +def _held(state: ExecutionPipelineState, asset_id: str) -> Decimal: + position = state.portfolio.positions.get(asset_id) + return Decimal("0") if position is None else position.quantity + + +def _strategy_parts( + shares: tuple[tuple[str, Decimal], ...], closing: Decimal +) -> dict[str, Decimal]: + """Each strategy's part of ``closing`` contracts, from its own signed share. + + The whole position is each share as it stands. Part of it is apportioned in + proportion to the shares -- whole contracts by largest remainder, ties to the + earlier strategy, when every share is whole -- and is refused when the + strategies hold opposite sides: which side a part of a netted book belongs to + is not something the book can say. + """ + + if not shares: + return {} + total = sum((held for _, held in shares), Decimal("0")) + if closing == total: + return dict(shares) + if any((held > 0) != (closing > 0) for _, held in shares): + raise RuntimeValidationError( + "Part of a position whose strategies hold opposite sides cannot be attributed to " + "them; exercise or assign the whole position." + ) + ctx = ACCOUNTING_CONTEXT + if all(held == held.to_integral_value() for _, held in shares): + # Whole contracts, by the one largest-remainder rule the algorithms + # package apportions a schedule by. + units = _largest_remainder(int(closing.copy_abs()), [held.copy_abs() for _, held in shares]) + sign = Decimal("1") if closing > 0 else Decimal("-1") + return {sid: sign * Decimal(part) for (sid, _), part in zip(shares, units, strict=True)} + parts = {sid: ctx.divide(ctx.multiply(closing, held), total) for sid, held in shares[:-1]} + parts[shares[-1][0]] = ctx.subtract(closing, sum(parts.values(), Decimal("0"))) + return parts + + +def _contributions(parts: Mapping[str, Decimal]) -> tuple[StrategyContribution, ...]: + return tuple(StrategyContribution(sid, part.copy_abs()) for sid, part in parts.items() if part) + + +def _lifecycle_fill( + state: ExecutionPipelineState, + asset_id: str, + quantity: Decimal, + price: Decimal, + timestamp: float, + currency: str, + economics: InstrumentEconomics, + parts: Mapping[str, Decimal], + trade_id: str, +) -> ExecutionPipelineState: + """Book ``quantity`` of ``asset_id`` at ``price`` through the portfolio's fill authority. + + No commission: a lifecycle event is not a trade the venue charges for as one. + The trade record carries the realized P&L and the strategies it belongs to, + and its identifier is derived, never drawn from the run's stream. + """ + + portfolio = state.portfolio + before = len(portfolio.events) + opened_at = _opened_at(portfolio, asset_id) + portfolio = PortfolioEngine.apply_fill( + portfolio, + asset_id, + quantity, + price, + Decimal("0"), + timestamp, + currency, + economics=economics, + final_settlement=True, + ) + realized = Decimal("0.00") + for event in portfolio.events[before:]: + if isinstance(event, PositionReduced | PositionClosed): + realized = event.realized_pnl + break + record = TradeRecord( + trade_id=trade_id, + asset_id=asset_id, + sector_id=_sector_for(state, asset_id), + realized_pnl=realized, + notional_value=ACCOUNTING_CONTEXT.multiply(quantity.copy_abs(), price), + holding_period_seconds=None if opened_at is None else timestamp - opened_at, + contributions=_contributions(parts), + ) + return evolve(state, portfolio=portfolio, trade_records=state.trade_records.append(record)) + + +def _deliverable_multiplier( + state: ExecutionPipelineState, contract: InstrumentRecord, deliverable: Deliverable +) -> Decimal: + """What one quoted unit of the deliverable is worth per unit of its price. + + Read from the deliverable's own declaration -- a cash-settled index option + names its index as an instrument too -- so a contract on a future or on an + instrument with a multiplier of its own is valued by it, not by an assumed one. + """ + + underlying = _declared(state, deliverable.asset_id) + if underlying is None: + raise RuntimeValidationError( + f"{contract.symbol} on {contract.exchange} settles against " + f"{deliverable.asset_id}, which the run's registry does not declare; what one unit " + "of it is worth is that declaration's to say." + ) + named = f"{underlying.symbol} on {underlying.exchange}" + return economics_for(underlying.asset_type, underlying.economics, named).multiplier + + +def _require_deliverable( + state: ExecutionPipelineState, + contract: InstrumentRecord, + deliverable: Deliverable, + units: Decimal, + price: Decimal, + multiplier: Decimal, +) -> tuple[InstrumentRecord, InstrumentEconomics, Decimal]: + """The deliverable's record, its economics and the price per unit it moves at -- or a refusal. + + ``units`` is signed: positive is taken in, negative delivered away. ``price`` + is per unit of the contract's price, so ``price x multiplier`` is one + contract's consideration, and the unit price is that over the deliverable + units and the deliverable's own multiplier. + """ + + named = f"{contract.symbol} on {contract.exchange}" + underlying = _declared(state, deliverable.asset_id) + if underlying is None: + raise RuntimeValidationError( + f"{named} delivers {deliverable.asset_id}, which the run's registry does not " + "declare; nothing can be delivered that the book cannot name." + ) + if underlying.currency != contract.currency: + raise RuntimeValidationError( + f"{named} is paid for in {contract.currency} and delivers {underlying.symbol}, " + f"priced in {underlying.currency}; a delivery whose consideration and deliverable " + "are in two currencies is not modelled." + ) + economics = economics_for( + underlying.asset_type, underlying.economics, f"{underlying.symbol} on {underlying.exchange}" + ) + ctx = ACCOUNTING_CONTEXT + per_contract_units = deliverable.units + unit_price = ctx.divide( + ctx.multiply(price, multiplier), ctx.multiply(per_contract_units, economics.multiplier) + ) + if not economics.admits_price(unit_price): + raise RuntimeValidationError( + f"{named} would deliver {underlying.symbol} at {unit_price}, which its economics do " + "not admit." + ) + held = _held(state, deliverable.asset_id) + if ( + units < 0 + and ctx.add(held, units) < 0 + and not state.config.allocation_constraints.allow_shorting + ): + raise RuntimeValidationError( + f"{named} must deliver {-units} {underlying.symbol} and the book holds {held}; the " + "run does not allow a short position, and none is created to make the delivery." + ) + if units > 0 and economics.settlement.pays_notional: + cost = ctx.multiply(ctx.multiply(units, unit_price), economics.multiplier) + cash = state.portfolio.cash.balance(underlying.currency) + if cash < cost: + raise RuntimeValidationError( + f"{named} takes delivery of {units} {underlying.symbol} for {cost} " + f"{underlying.currency}, and the book holds {cash}; no financing of a delivery " + "is modelled." + ) + return underlying, economics, unit_price + + +def _deliver( + state: ExecutionPipelineState, + contract: InstrumentRecord, + deliverable: Deliverable, + parts: Mapping[str, Decimal], + units: Decimal, + price: Decimal, + multiplier: Decimal, + timestamp: float, + rates: FxRates, + trade_id: str, +) -> ExecutionPipelineState: + """Move ``units`` of the deliverable against ``price`` per unit of the contract's price.""" + + underlying, economics, unit_price = _require_deliverable( + state, contract, deliverable, units, price, multiplier + ) + current = _lifecycle_fill( + state, + deliverable.asset_id, + units, + unit_price, + timestamp, + underlying.currency, + economics, + parts, + trade_id, + ) + budget_value = _in_budget_currency( + current, + ACCOUNTING_CONTEXT.multiply(unit_price, economics.multiplier), + underlying.currency, + rates, + timestamp, + ) + current = evolve( + current, + allocation=AllocationEngine.apply_lifecycle( + current.allocation, deliverable.asset_id, {}, deliverable.asset_id, parts, budget_value + ), + ) + # The delivered position is worth the market's price for it, when the run has + # one, not the price it was delivered at. + if deliverable.asset_id in current.market_prices: + current = evolve( + current, + portfolio=PortfolioEngine.mark_changed( + current.portfolio, current.market_prices, timestamp, deliverable.asset_id + ), + ) + return current + + +def _resolve_option( + state: ExecutionPipelineState, + record: InstrumentRecord, + terms: OptionTerms, + economics: InstrumentEconomics, + closing: Decimal, + underlying_value: Decimal | None, + timestamp: float, + rates: FxRates, + trade_id: str, +) -> ExecutionPipelineState: + """Exercise (``closing`` > 0) or assign (< 0) contracts by the option's terms.""" + + named = f"{record.symbol} on {record.exchange}" + asset_id = record.asset_id + parts = _strategy_parts(AllocationEngine.shares_of(state.allocation, asset_id), closing) + cash_settled = terms.settlement is SettlementStyle.CASH + if cash_settled and underlying_value is None: + raise RuntimeValidationError( + f"{named} is cash-settled: its exercise pays the intrinsic value against the " + "underlying's settlement value, which was not supplied." + ) + if not cash_settled and underlying_value is not None: + raise RuntimeValidationError( + f"{named} is physically settled: the deliverable moves against the strike, and a " + "settlement value would be read by nothing." + ) + direction = Decimal("1") if terms.option_type is OptionType.CALL else Decimal("-1") + delivered_units = ACCOUNTING_CONTEXT.multiply( + ACCOUNTING_CONTEXT.multiply(direction, terms.deliverable.units), closing + ) + if not cash_settled: + # Refused before anything moves. + _require_deliverable( + state, record, terms.deliverable, delivered_units, terms.strike, economics.multiplier + ) + current = evolve( + state, allocation=AllocationEngine.apply_lifecycle(state.allocation, asset_id, parts) + ) + if cash_settled: + assert underlying_value is not None + paid = terms.intrinsic( + underlying_value, + economics.multiplier, + _deliverable_multiplier(current, record, terms.deliverable), + ) + current = _lifecycle_fill( + current, + asset_id, + -closing, + paid, + timestamp, + record.currency, + economics, + parts, + trade_id, + ) + else: + # The contracts are surrendered for nothing; the deliverable moves + # against the strike. + current = _lifecycle_fill( + current, + asset_id, + -closing, + Decimal("0"), + timestamp, + record.currency, + economics, + parts, + trade_id, + ) + current = _deliver( + current, + record, + terms.deliverable, + { + sid: ACCOUNTING_CONTEXT.multiply( + ACCOUNTING_CONTEXT.multiply(direction, terms.deliverable.units), part + ) + for sid, part in parts.items() + }, + delivered_units, + terms.strike, + economics.multiplier, + timestamp, + rates, + f"DELIVERY-{trade_id}", + ) + return _after_book_change(current, current.portfolio, timestamp, rates) + + def _economics_of(state: ExecutionPipelineState, asset_id: str) -> InstrumentEconomics | None: """The economics ``asset_id`` is booked by: what its record declares (ledger ACC-005). @@ -1959,6 +2894,9 @@ def _working_shares( return shares +#: The settlement models of what expires: futures and options (FUT-012). +_EXPIRING: Final = frozenset({SettlementModel.FUTURES_VARIATION, SettlementModel.OPTION_PREMIUM}) + _NO_LOTS: Mapping[str, LotSpecification] = MappingProxyType({}) _NO_MINIMUMS: Mapping[str, Decimal] = MappingProxyType({}) _NO_MULTIPLIERS: Mapping[str, Decimal] = MappingProxyType({}) @@ -2351,8 +3289,12 @@ def _terms_refusal( f"The order would be placed at {instant!r}, and its terms expire it at " f"{terms.expire_at!r}." ) - if state.config.routing is not ExecutionRouting.SIMULATED: + if state.config.routing is ExecutionRouting.EXTERNAL: return None + if state.config.routing is ExecutionRouting.ALGORITHMIC: + unworkable = _algorithmic_terms_refusal(terms) + if unworkable is not None: + return unworkable if _needs_session_close(terms): _, refusal = _day_order_close(state, request.asset_id, instant) if refusal is not None: @@ -2365,6 +3307,30 @@ def _terms_refusal( return None +def _algorithmic_terms_refusal(terms: OrderTerms) -> str | None: + """Why an execution algorithm cannot work a parent on these terms, or ``None``. + + An algorithm's children are market or limit orders + (:class:`~alphalab.execution.algorithms.AlgorithmTerms`), sent over a window: + a stop has no trigger to slice, an auction is one price at one instant, and an + immediate order ends at its first attempt, which a window contradicts. + Refused before an order exists, so nothing is held and nothing is filled + whole in its place. + """ + + if terms.order_type not in (OrderType.MARKET, OrderType.LIMIT): + return ( + f"An execution algorithm works MARKET or LIMIT parents in children, not " + f"{terms.order_type}; the order was not placed." + ) + if terms.is_auction or terms.is_immediate: + return ( + f"An execution algorithm works a parent over a window, and a " + f"{terms.time_in_force} order is one attempt; the order was not placed." + ) + return None + + def _needs_session_close(terms: OrderTerms) -> bool: """Whether a simulated order on these terms expires at a session close nobody stated.""" @@ -3215,7 +4181,9 @@ def _submit_and_accept_order( state: ExecutionPipelineState, request: OrderRequest, timestamp: float ) -> tuple[ExecutionPipelineState, OMSOrder]: expire_at = request.terms.expire_at - if state.config.routing is ExecutionRouting.SIMULATED and _needs_session_close(request.terms): + if state.config.routing is not ExecutionRouting.EXTERNAL and _needs_session_close( + request.terms + ): # _terms_refusal already refused the order when this has no answer. expire_at, _ = _day_order_close(state, request.asset_id, timestamp) submitted_order = _oms_order(request, expire_at) @@ -3739,6 +4707,32 @@ def _settlement_refusal( return None currency = record.currency named = f"{record.symbol} on {record.exchange}" + if record.asset_type is AssetType.INDEX: + # v4.1.1 (FUT-013): an index is a value contracts settle against. + return SettlementRefusal( + asset_id=asset_id, + instrument_currency=currency, + settlement_currency=settlement, + detail=( + f"{named} is an index: a reference value, not something held. Trade a " + "contract on it; the request was dropped before it reached the OMS." + ), + timestamp=timestamp, + ) + if record.last_trade_at is not None and timestamp > record.last_trade_at: + # v4.1.1 (FUT-012): an expired contract trades no more, here or anywhere. + return SettlementRefusal( + asset_id=asset_id, + instrument_currency=currency, + settlement_currency=settlement, + detail=( + f"{named} stopped trading at {record.last_trade_at!r} and the request is at " + f"{timestamp!r}: an expired contract is not traded. The request was dropped " + "before it reached the OMS; a position still held is closed by its " + "ContractSettlement." + ), + timestamp=timestamp, + ) if currency in permitted: # Since v3.11 a request must also be one the book can be kept by: a # future or an option declaring no economics has a multiplier and a @@ -3909,6 +4903,13 @@ def price_refusal(state: ExecutionPipelineState, payload: object) -> str | None: asset_id, mark, shown = payload.asset_id, payload.price, (payload.price,) else: return None + expired = _expired_at(state, asset_id) + if expired is not None and payload.timestamp > expired: + return ( + f"{asset_id} stopped trading at {expired!r} and this record is at " + f"{payload.timestamp!r}: an expired contract has no price to mark or trade on " + "(FUT-012), so the record is refused." + ) if mark > 0 and all(price >= 0 for price in shown): return None economics = _economics_of(state, asset_id) diff --git a/alphalab/runtime/live.py b/alphalab/runtime/live.py index 87695dff..1ab83b2c 100644 --- a/alphalab/runtime/live.py +++ b/alphalab/runtime/live.py @@ -100,8 +100,10 @@ from collections.abc import Iterable, Sequence from dataclasses import dataclass, field +from typing import Any from uuid import UUID +from alphalab.alt_data.streaming import ObservationDelivery from alphalab.broker.events import BrokerEvent from alphalab.broker.execution import BrokerExecution from alphalab.broker.protocol import BrokerProtocol @@ -642,6 +644,37 @@ def close_slice( return current, (), () return LiveSession.route_working_orders(current, broker, now) + @staticmethod + @in_accounting_context + def deliver_observation( + state: LiveRunState, + delivery: ObservationDelivery[Any], + broker: BrokerProtocol, + context_factory: ContextFactory, + now: float, + rates: FxRates = NO_RATES, + *, + route: bool = True, + ) -> tuple[LiveRunState, tuple[RoutedOrder, ...], tuple[BrokerEvent, ...]]: + """Deliver a record that has arrived, and send what the strategies ask for (v4.1.1). + + :meth:`~alphalab.runtime.run.RunEngine.deliver_observation` with the + session's clock ``now``: a record that arrives after it became knowable + is delivered when it arrived, never earlier, and one delivered before is + not delivered again -- across a restart too, since the run's observation + cursor is in its snapshot (ledger OFE-009). Then + :meth:`route_working_orders` at ``now``, as :meth:`close_slice` does. + Until v4.1.1 a live host reached into ``state.run`` to deliver one and + had no session call that routed what it produced. ``route=False`` leaves + the routing to the caller, as for :meth:`advance`. + """ + + run = RunEngine.deliver_observation(state.run, delivery, context_factory, rates, now=now) + current = evolve(state, run=run) + if not route: + return current, (), () + return LiveSession.route_working_orders(current, broker, now) + @staticmethod @in_accounting_context def apply_cash_flow( diff --git a/alphalab/runtime/run.py b/alphalab/runtime/run.py index 2512636f..8c51288f 100644 --- a/alphalab/runtime/run.py +++ b/alphalab/runtime/run.py @@ -70,10 +70,17 @@ from alphalab.market.source import OrderingGuarantee from alphalab.market.state import MarketState from alphalab.oms.order import Order as OMSOrder -from alphalab.portfolio.corporate_actions import CashFlow, Split +from alphalab.portfolio.corporate_actions import ( + Assignment, + CashFlow, + ContractSettlement, + Exercise, + OptionExpiry, + Split, +) from alphalab.portfolio.fx import NO_RATES, FxRates from alphalab.runtime.assumptions import ExecutionAssumptions, execution_assumptions -from alphalab.runtime.exceptions import AlphaLabRuntimeError +from alphalab.runtime.exceptions import AlphaLabRuntimeError, RuntimeValidationError from alphalab.runtime.execution_pipeline import ( ContextFactory, ExecutionPipeline, @@ -82,6 +89,7 @@ ExecutionPipelineState, ExecutionRouting, UnpricedAsset, + held_past_expiry, price_refusal, retained, wants_slices, @@ -116,12 +124,29 @@ class ExecutionMode(Enum): @property def routing(self) -> ExecutionRouting: - """Where an accepted order executes in this mode.""" + """Where an accepted order executes in this mode, unless the run works it in children. + + A historical run may instead route + :attr:`~alphalab.runtime.execution_pipeline.ExecutionRouting.ALGORITHMIC` + (v4.1.1): see :attr:`works_children`. + """ return ( ExecutionRouting.EXTERNAL if self is ExecutionMode.LIVE else ExecutionRouting.SIMULATED ) + @property + def works_children(self) -> bool: + """Whether a run in this mode may have an execution algorithm work its orders in children. + + A backtest and a replay (v4.1.1, ledger FUT-009). A live run's children + are the venue's to fill, through + :func:`~alphalab.runtime.broker_routing.route_child_order`, and a paper + run's are its broker's. + """ + + return self in {ExecutionMode.BACKTEST, ExecutionMode.REPLAY} + @property def is_realtime(self) -> bool: """Whether records arrive against a moving wall clock. @@ -230,8 +255,16 @@ def __post_init__(self) -> None: if self.seed is not None: require_seed(self.seed, "RunConfig.seed") # The mode decides routing; a config that disagreed with its own mode - # would execute one way and describe itself another. - if self.pipeline.routing is not self.mode.routing: + # would execute one way and describe itself another. A historical run + # may work its orders in children instead (FUT-009); any other run asked + # to is refused, not quietly routed some other way. + if self.pipeline.routing is ExecutionRouting.ALGORITHMIC: + if not self.mode.works_children: + raise RuntimeValidationError( + f"A {self.mode.name} run cannot route ALGORITHMIC: its children are its " + "venue's to fill. Route them with route_child_order." + ) + elif self.pipeline.routing is not self.mode.routing: object.__setattr__(self, "pipeline", evolve(self.pipeline, routing=self.mode.routing)) @property @@ -752,6 +785,109 @@ def apply_split( state, pipeline=pipeline, current_timestamp=max(state.current_timestamp, clock) ) + @staticmethod + @in_accounting_context + def fund( + state: RunState, + amount: Decimal, + currency: str, + timestamp: float | None = None, + rates: FxRates = NO_RATES, + ) -> RunState: + """Deposit cash in a currency this run settles, at ``timestamp`` (v4.1.1). + + ``timestamp`` defaults to the run's current instant. See + :meth:`~alphalab.runtime.execution_pipeline.ExecutionPipeline.fund`: the + way a run that settles a second currency gets cash in it, which no fill + conjures for itself. + """ + + clock = state.current_timestamp if timestamp is None else timestamp + pipeline = ExecutionPipeline.fund(state.pipeline, amount, currency, clock, rates) + return evolve( + state, pipeline=pipeline, current_timestamp=max(state.current_timestamp, clock) + ) + + @staticmethod + @in_accounting_context + def settle_contract( + state: RunState, + settlement: ContractSettlement, + timestamp: float | None = None, + rates: FxRates = NO_RATES, + ) -> RunState: + """Close every position in an expired contract at its final settlement (FUT-012). + + ``timestamp`` defaults to the run's current instant. See + :meth:`~alphalab.runtime.execution_pipeline.ExecutionPipeline.settle_contract`. + """ + + clock = state.current_timestamp if timestamp is None else timestamp + pipeline = ExecutionPipeline.settle_contract(state.pipeline, settlement, clock, rates) + return evolve( + state, pipeline=pipeline, current_timestamp=max(state.current_timestamp, clock) + ) + + @staticmethod + @in_accounting_context + def exercise( + state: RunState, + exercise: Exercise, + timestamp: float | None = None, + rates: FxRates = NO_RATES, + ) -> RunState: + """Exercise contracts of a long option, by its declared terms (FUT-013). + + ``timestamp`` defaults to the run's current instant. See + :meth:`~alphalab.runtime.execution_pipeline.ExecutionPipeline.exercise`. + """ + + clock = state.current_timestamp if timestamp is None else timestamp + pipeline = ExecutionPipeline.exercise(state.pipeline, exercise, clock, rates) + return evolve( + state, pipeline=pipeline, current_timestamp=max(state.current_timestamp, clock) + ) + + @staticmethod + @in_accounting_context + def assign( + state: RunState, + assignment: Assignment, + timestamp: float | None = None, + rates: FxRates = NO_RATES, + ) -> RunState: + """Assign contracts of a short option, by its declared terms (FUT-013). + + ``timestamp`` defaults to the run's current instant. See + :meth:`~alphalab.runtime.execution_pipeline.ExecutionPipeline.assign`. + """ + + clock = state.current_timestamp if timestamp is None else timestamp + pipeline = ExecutionPipeline.assign(state.pipeline, assignment, clock, rates) + return evolve( + state, pipeline=pipeline, current_timestamp=max(state.current_timestamp, clock) + ) + + @staticmethod + @in_accounting_context + def expire_option( + state: RunState, + expiry: OptionExpiry, + timestamp: float | None = None, + rates: FxRates = NO_RATES, + ) -> RunState: + """Resolve every position in an option at its expiry, by its declared terms (FUT-013). + + ``timestamp`` defaults to the run's current instant. See + :meth:`~alphalab.runtime.execution_pipeline.ExecutionPipeline.expire_option`. + """ + + clock = state.current_timestamp if timestamp is None else timestamp + pipeline = ExecutionPipeline.expire_option(state.pipeline, expiry, clock, rates) + return evolve( + state, pipeline=pipeline, current_timestamp=max(state.current_timestamp, clock) + ) + @staticmethod @in_accounting_context def stop( @@ -786,8 +922,27 @@ def finalize(state: RunState) -> RunState: Separate from any driver's result type: a driver turns a finished :class:`RunState` into whatever it reports, and this is the last thing the runtime itself does to one. + + Raises: + RuntimeValidationError: If the book still holds a contract past its + declared last trading instant (v4.1.1, FUT-012): a run is not + finished with a position no contract could still have. """ + # The latest instant the run was handed: a record skipped after expiry + # is a record of time passing even though it moved nothing else. + latest = state.current_timestamp + if len(state.skipped): + latest = max(latest, state.skipped[-1].record.timestamp) + expired = held_past_expiry(state.pipeline, latest) + if expired: + raise RuntimeValidationError( + f"The run ends holding {len(expired)} contract(s) past their last trading " + f"instant ({', '.join(expired)}) with no settlement: a position the contract " + "could not have had would be reported as held and marked at its last price. " + "Close it before expiry, or apply its ContractSettlement (FUT-012) -- or, for an " + "option whose terms are declared, its OptionExpiry (FUT-013)." + ) if not state.config.compile_analytics: return state diff --git a/benchmarks/benchmark_algorithmic_execution.py b/benchmarks/benchmark_algorithmic_execution.py new file mode 100644 index 00000000..84868d04 --- /dev/null +++ b/benchmarks/benchmark_algorithmic_execution.py @@ -0,0 +1,209 @@ +"""Benchmark for execution algorithms on the historical path (v4.1.1, ledger FUT-009). + +A strategy places a 100-share parent every 20 records, and a TWAP works each in +four children over eight records, so a 4x dataset carries 4x the parents and 4x +the children. The run reports throughput and the judged scaling factor, and +what working orders in children costs against the same run filling them whole. + +The algorithm book is worked once per record for the record's asset, and holds +only open parents, so its cost per record is bounded by the parents working at +once -- not by every parent the run ever placed. A factor well above linear would +mean that bound had been lost. +""" + +import time +from collections.abc import Iterable +from dataclasses import replace +from decimal import Decimal +from typing import Any +from uuid import uuid4 + +from _stable_timing import SAMPLES, fastest + +from alphalab.allocation.budget import CapitalBudget +from alphalab.allocation.constraints import AllocationConstraints +from alphalab.backtesting import ( + AlgorithmAssignment, + AlgorithmicResult, + BacktestEngine, + BacktestResult, + ExecutionMode, + ExecutionPlan, + MarketDataset, + RunConfig, +) +from alphalab.execution.algorithms import TWAP, Urgency +from alphalab.execution.policy import FillTiming +from alphalab.market.quote import Quote +from alphalab.portfolio.account import Account +from alphalab.risk.limits import ( + DailyLossLimit, + DrawdownLimit, + ExposureLimit, + LeverageLimit, + MarginLimit, + OrderSizeLimit, + PositionLimit, + RiskLimits, +) +from alphalab.runtime.execution_pipeline import ExecutionPipelineConfig +from alphalab.strategy import DiscardingLogger, FixedClock, context_factory +from alphalab.strategy.context import StrategyContext +from alphalab.strategy.events import Intent +from alphalab.strategy.protocol import BaseStrategy +from alphalab.strategy.runtime import create_runtime, register_strategy +from alphalab.strategy.state import RuntimeState +from alphalab.strategy.supervisor import RuntimeSupervisor + +START_CASH = Decimal("10000000000") +SEED = 20809 +EVERY = 20 +MAX_SCALING_FACTOR = 6.0 +#: Children are extra fills, each through the whole accounting path, and the +#: book is worked at every record; a few times the whole-order run, not more. +MAX_ALGORITHMIC_OVERHEAD = 4.0 +CONTEXTS = context_factory(FixedClock(0.0), DiscardingLogger()) +PLAN = ExecutionPlan(AlgorithmAssignment(TWAP(4, Urgency.neutral()), 8.0, Decimal("1"))) + + +class _Accumulator(BaseStrategy): + """Buys 100 shares every ``EVERY`` records.""" + + def __init__(self, strategy_id: str, asset_id: str) -> None: + self._strategy_id = strategy_id + self._asset_id = asset_id + + def on_quote(self, context: StrategyContext, event: Any) -> Iterable[Intent]: + if int(event.quote.timestamp) % EVERY != 0: + return () + return ( + Intent( + self._strategy_id, self._asset_id, Decimal("100"), timestamp=event.quote.timestamp + ), + ) + + +def _running(strategy_id: str, asset_id: str) -> RuntimeState: + state = register_strategy(create_runtime(), strategy_id, _Accumulator(strategy_id, asset_id)) + strategy = state.strategies[strategy_id] + strategy, _ = RuntimeSupervisor.configure(strategy, {}, 1.0) + strategy, _ = RuntimeSupervisor.initialize(strategy, 1.1) + strategy, _ = RuntimeSupervisor.subscribe(strategy, frozenset({"quotes"}), 1.2) + strategy, _ = RuntimeSupervisor.start(strategy, 1.3) + return replace(state, strategies={strategy_id: strategy}) + + +def _config(strategy_id: str) -> RunConfig: + huge = Decimal("100000000000") + return RunConfig( + mode=ExecutionMode.BACKTEST, + pipeline=ExecutionPipelineConfig( + account=Account("acct-algo-bench", "USD", "Algorithmic Benchmark Account", 1.0), + starting_cash=START_CASH, + budget=CapitalBudget( + global_capital=START_CASH, + maximum_exposure=huge, + cash_buffer=Decimal("0"), + strategy_budgets={strategy_id: START_CASH}, + ), + allocation_constraints=AllocationConstraints( + allow_shorting=True, enforce_integer_quantities=False + ), + risk_limits=RiskLimits( + order_size=OrderSizeLimit(huge, huge), + position=PositionLimit(huge, huge), + exposure=ExposureLimit(huge, huge), + leverage=LeverageLimit(Decimal("1000")), + margin=MarginLimit(Decimal("1.00")), + daily_loss=DailyLossLimit(huge, "UTC"), + drawdown=DrawdownLimit(Decimal("1.00")), + ), + fill_timing=FillTiming.NEXT_EVENT, + ), + seed=SEED, + start_timestamp=1.0, + ) + + +def _dataset(asset_id: str, records: int) -> MarketDataset: + return MarketDataset.of( + "BENCH-ALGO", + [ + Quote( + asset_id=asset_id, + timestamp=2.0 + index, + bid=Decimal("100.005") + Decimal(index % 20), + ask=Decimal("100.005") + Decimal(index % 20), + bid_size=Decimal("1000"), + ask_size=Decimal("1000"), + venue="SIM", + currency="USD", + ) + for index in range(records) + ], + ) + + +def _workload(records: int, plan: ExecutionPlan | None) -> Any: + strategy_id, asset_id = str(uuid4()), str(uuid4()) + config, dataset = _config(strategy_id), _dataset(asset_id, records) + + def work() -> BacktestResult: + return BacktestEngine.run( + config, dataset, _running(strategy_id, asset_id), CONTEXTS, execution=plan + ) + + return work + + +def _check(result: BacktestResult, records: int) -> int: + if not isinstance(result, AlgorithmicResult): + raise SystemExit("The algorithmic run returned no book.") + book = result.algorithms + parents = records // EVERY + children = sum( + len(f.algorithm.children) for f in book.finished.values() if f.algorithm is not None + ) + sum(len(a.children) for a in book.incomplete) + held = len(book.finished) + len(book.incomplete) + if held != parents: + raise SystemExit(f"Expected {parents} parents, the book holds {held}.") + return children + + +def run_benchmark() -> None: + small, large = 1_000, 4_000 + print("Starting Algorithmic Execution Benchmark...") + for records in (small, large): + start = time.perf_counter() + result = _workload(records, PLAN)() + elapsed = time.perf_counter() - start + children = _check(result, records) + print( + f" records={records:>6} parents={records // EVERY:>4} children={children:>5} " + f"time={elapsed:.4f}s ({records / max(elapsed, 1e-9):,.1f} records/sec)" + ) + + small_cpu, large_cpu, whole_cpu = fastest( + [_workload(small, PLAN), _workload(large, PLAN), _workload(large, None)] + ) + scaling = large_cpu / max(small_cpu, 1e-9) + overhead = large_cpu / max(whole_cpu, 1e-9) + print( + f" judged (CPU time, collector paused, fastest of {SAMPLES}): 4x workload cost " + f"{scaling:.2f}x the time; working orders in children cost {overhead:.2f}x filling " + "them whole" + ) + if scaling > MAX_SCALING_FACTOR: + raise SystemExit( + f"Algorithmic scaling factor {scaling:.2f}x exceeds {MAX_SCALING_FACTOR:.2f}x; the " + "child path has regressed toward quadratic behaviour." + ) + if overhead > MAX_ALGORITHMIC_OVERHEAD: + raise SystemExit( + f"Working orders in children cost {overhead:.2f}x filling them whole, above " + f"{MAX_ALGORITHMIC_OVERHEAD:.2f}x." + ) + + +if __name__ == "__main__": + run_benchmark() diff --git a/docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md b/docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md new file mode 100644 index 00000000..2740425b --- /dev/null +++ b/docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md @@ -0,0 +1,269 @@ +# ADR-0051: v4.1.1 — Information on Every Driver, Several Datasets in One Run, and the Strategy Capability Audit + +## Status + +**Accepted and implemented in the v4.1.1 candidate** (branch +`work/v4.1.1-future-readiness`, uncommitted), in two milestones: the audit and +information on every driver (decisions 1-6), then the continuation that closed +the engine-owned gaps the audit found (decisions 7-12), then the FUT-009 +milestone, execution algorithms on the historical drivers (decision 13), then +the FUT-013 milestone, a derivative's lifecycle declared and carried out +(decision 14). The release number is the +maintainer's decision: the candidate adds public names, keyword-only parameters, +an enum member and two schema steps (instrument 3 to 4, replay 1 to 2, each +reading every earlier payload), which `docs/api/PUBLIC_API.md`'s policy and +semantic versioning make a **minor** release (v4.2.0), not a patch; the ledger +records the entries under `v4.2.0`, the recommendation. It adds **no package** +and no package edge but `alphalab.api -> alphalab.macro`; no run, pipeline or +portfolio schema moves, and no v4.1 run identity moves. + +--- + +# Context + +The brief for v4.1.1 asked whether a researcher can devise a strategy AlphaLab's +authors did not anticipate -- combining assets, datasets, sentiment, macro +releases, features and models -- validate it honestly and carry it towards +production, and asked for the answer from source, tests and executed runs. The +audit (`docs/audit/STRATEGY_CAPABILITY_MATRIX.md`, +`docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md`) found the strategy +contract open -- a registry maps an identity to any factory, and no list of +strategy kinds exists -- and point-in-time information on the execution path +since v3.12 (OFE-009); and it found four places where composition stopped: + +1. Only the backtest driver delivered information. A replay of the same records + took none, so the replay of a strategy acting on news was another run; the + application facade took none; the live driver had no call for it. +2. A run named one dataset, and a dataset is one series kind from one source. A + two-venue or two-provider run carried an id its author typed. +3. `context.market.bar(asset_id)` had returned `None` for every asset since + v2.10 (KD-005), so a strategy reading bars through its context saw none. +4. The reinforcement-learning package still reported a fixed accounting defect + as live (DOC-012). + +--- + +# Decisions + +**1. One merge rule, every historical driver.** The rule `BacktestEngine.run` +has applied since v3.12 -- an observation at its knowledge instant, after every +record of that instant and before its slice closes; one known after the data +never -- becomes three public functions in `alphalab.backtesting.engine` +(`pending_observations`, `deliver_known_before`, `deliver_remaining`), and +`BacktestEngine.run` and `ReplayBacktest.run` both call them. A caller stepping +a run by hand calls them too, and `pending_observations` given a restored run's +cursor hands a continuation exactly the deliveries it has not made. + +**2. A paused replay carries what it has still to deliver -- in process.** +`PausedReplay.observations` (defaulted, so every existing construction is +unchanged) holds the outstanding deliveries, checked to be after the run's +cursor and in order. Replay schema 1 records the cursor and not the schedule, +so `replay_snapshot.capture` **refuses** a paused replay with deliveries +outstanding that its data reaches, rather than write a payload that would +resume without them; deliveries known only after the last record are never +made by any replay of it, and do not refuse. The durable form is a schema step, +accepted as FUT-003. + +**3. The facade and the live driver say it too.** `alphalab.api.backtest` and +`replay` take keyword-only `observations`; `LiveSession.deliver_observation` +delivers at the session's clock (never before the record was knowable) and +routes what the strategies asked for, as `LiveSession.close_slice` does. + +**4. Several datasets, one derived identity.** `alphalab.api.CombinedDataset` +keeps each part's provenance; its version is derived from the parts' versions +and content hashes **in the order given**, because that order breaks ties +between records sharing an instant. Records are merged by time, part and +position; nothing is aligned, filled or carried forward; one instrument's price +from two parts at one instant is refused. It satisfies the manifest's +`VersionedDataset` protocol, so `manifest_for_run` checks a run against it. + +**5. A context's bar is the bar the market holds.** `MarketView.bar` finds the +asset's bars by their key and returns the latest, the longer interval on a tie +(KD-005). + +**6. The acceptance scenario is an example, and a test holds it.** +`examples/72_compose_a_multi_asset_strategy.py` -- an equity and an index future +from two files, social-media posts and a CPI series with revisions, a +researcher-written strategy, costs, a refusing risk gate, a manifest -- is held +by `tests/integration/test_multi_asset_composition.py` to eleven properties, +including a counterfactual (late values changed; nothing before their +availability moves) and a control that leaks and is caught. + +--- + +**7. One schedule for every timed input.** `alphalab.backtesting.schedule` +replaces decision 1's three observation functions (never released) with a +`RunSchedule` -- observations, rates, funding, cash flows, per-unit +distributions, perpetual funding rates, splits, timers and contract settlements +-- and one `ScheduleCursor` both historical drivers and a hand loop take. Each +entry is placed *before* the records of its instant (what the world is at the +instant: a rate, a funding, a cash flow, a split) or *after* them (what happens +at it: an observation, a timer, a settlement), ordered by +`(instant, place, kind, identity)`. Two entries naming one thing at one instant +are refused. What has been applied is a function of the last record a run was +handed, so a continuation needs only the schedule back, never a counter. + +**8. Per-unit distributions and funding rates.** A schedule is written before +the run, so it cannot know a position's size; a dividend or coupon is declared +per unit and a funding rate as a rate, and the run books what the position held +at the instant earns or owes, at the run's own mark. + +**9. A paused replay keeps its schedule durably.** Replay schema 2 records the +schedule's digest; `restore` takes the schedule back and refuses another or none. +Decision 2's refusal is withdrawn. + +**10. Contracts expire.** `InstrumentRecord.last_trade_at` (outside the identity +key; instrument schema 4) declares the last trading instant, read through a +registry index so a registry with none costs the per-record path nothing. After +it the instrument's records are skipped and its requests refused before an order +exists; `ContractSettlement` closes every position at the exchange's final price +through the portfolio's one fill authority (`final_settlement=True` admits a +zero, so an option can expire worthless), cancels working orders, attributes the +settlement, and a run that ends holding an expired contract is refused. +Physical delivery and early exercise are not modelled; settling before the last +trading instant is refused. + +**11. Bonds.** `AssetType.BOND` declares its economics like a future and its +maturity as its last trading instant; `alphalab.api.bond_schedule` turns a +`macro.bond.Bond` into per-unit coupons and a redemption settlement. Accrual is +carried by trading full prices. + +**12. Lineage and research outputs.** `manifest_for_run(..., schedule=)` checks +the schedule against the run's deliveries and records its digest, rendered into +the identity only when given; a run over a combined dataset records each part, +checked to derive the combined identity, so a rerun is told which datasets to +supply and in what order. `alphalab.api.observations_from_indicator` lifts +the macro engine's releases. Example 73 evidences a study's features and a +fitted model on the execution path. + +**13. Execution algorithms on the historical drivers (FUT-009).** An +`ExecutionPlan` routes a backtest or replay +`ExecutionRouting.ALGORITHMIC`, a new member: the pipeline accepts and holds +every parent -- risk judges it whole, allocation reserves it, the OMS keeps one +order -- refuses stop, auction and immediate terms before an order exists, and +never simulates a parent whole. The algorithms' state is **driver state**, an +`AlgorithmBook` beside the run, as the schedule's cursor is: putting it on +`RunState` or the pipeline config would move every run's record and +`result_id`, which a minor release may not. Nor is it a field of +`BacktestResult`, which stores only the run so that nothing on it can disagree +with the run (pinned by `test_run_runtime_boundary.py`): a run given a plan +returns an `AlgorithmicResult`, the same projection with the book beside it. At each event of a parent's asset, +after the run's step, the book adopts new parents, expires children past their +slice, gives working children their turn, releases what the algorithm's rule +says is due, and ends a finished parent through `apply_terminal_outcome`. A turn +is `ExecutionPipeline.work_child`: the fill policy decides what the child's +quantity gets from the event's liquidity, the simulator prices it by the run's +cost model, and the report -- against the parent, with the parent-relative +status a venue fill has -- goes through the path every fill takes. Under +`NEXT_EVENT` a child trades only at a **later instant** than it was released, +stricter than an order's "next event", because a second record at the same +instant is the same moment. The completion criterion the audit wrote, equal to +the `PaperBroker` route fill for fill, is restated: a paper venue sees no market +data and fills a market child at the parent's reference price, so equality in +price would require the backtest to ignore each child's own bar; the backtest +releases the same children, by id, quantity and instant, as `route_child_order` +sends a `PaperBroker`, and prices them at their bars. The book persists in replay +schema 2 (unreleased, so no further step), its plan supplied back and held to it +by `plan_id`, and the manifest records the plan's id when given. + +**14. A derivative's lifecycle is declared by the contract and carried out by +the run (FUT-013).** `InstrumentRecord.terms` -- outside the identity key, like +`economics` -- declares `OptionTerms` or `FutureTerms`: the option type, strike, +exercise style and window, cash or physical settlement, one `Deliverable` in any +quantity per contract, and the clearing house's `AutomaticExercise` threshold or +none. Nothing is inferred from a market's name, country or currency, and there +is no list of supported markets: a market is its declared `MarketCalendar` +(zone, sessions, holidays) and `SettlementRule`, and +`alphalab.runtime.calendars.session_close` / `settles_at` turn those into a +lifecycle's instants, so a market whose conventions differ needs different +declarations, not different code (four synthetic profiles are tested, one a +Sunday-to-Thursday market with a lunch break). `Exercise`, `Assignment` and +`OptionExpiry` are inputs on the run's schedule -- after the instant's records, +exercise before assignment before expiry before settlement, so an instruction +for the expiry instant applies before the expiry resolves the rest -- and +`RunEngine` operations. Every movement goes through the portfolio's one fill +authority: a cash-settled contract closes at its intrinsic value per unit of +price, `max(S x units x m_deliverable / m - K, 0)`, against a supplied +settlement value; a physical one is surrendered at nothing while the deliverable +moves against `strike x multiplier` and is marked at the market's price; a +physical future delivers at its final price after its last variation. The +allocation ledger closes each strategy's share and books its share of the +delivery. Assignment before expiry is an input, never drawn: which written +contracts are assigned is not knowable from prices; at expiry the contract's +declared rule exercises longs and assigns shorts. An index is a new +`AssetType`, a reference value cash options settle against, never traded. What +cannot be represented or covered is refused before anything moves. + +--- + +# Consequences + +**Known limitations** + +- **A bond's accrued interest is carried by its price**: a run trading clean + prices books no accrual; trade full prices. +- **A feature evaluated inside a strategy recomputes its window**: equal to the + study's, and linear in the history per evaluation. +- **A run's record names its last delivery, not its schedule**: the schedule is + identified by the manifest, when the producer passes it. + +- **A child's fill reaches its strategy through the portfolio, not `on_fill`**: + as a venue fill does on the live path; the strategy sees it in its next + context. +- **Participation that completes after its window is refused**: no child + outlives its window in a historical run, so `COMPLETE_AT_END` has nowhere to + trade. +- **A child is checked against the venue's declaration, not by risk again**: + risk judged the parent, whose quantity bounds every child. +- **An algorithm is assigned per asset, not per strategy**: a netted parent + holds several strategies, and one parent is worked one way. +- **A strategy cannot instruct an exercise from its hooks**: exercise is a + scheduled input or a run operation, so a decision taken from data is a stated + policy over published prices. +- **Cash and deliverables move at the event's instant**: a market's T+n lag is + modelled by scheduling the event at `settles_at`'s instant; no unsettled-cash + ledger is kept. +- **One deliverable, in the consideration's currency, American or European**: + baskets, cash in lieu, quanto and Bermudan contracts are refused. +- **A delivery the book cannot cover stops the run**: no margin call, collateral + or forced liquidation is modelled. + +Each is stated in the ledger entry it belongs to (FUT-010, FUT-006, FUT-005, +FUT-009, FUT-014, FUT-015, FUT-016, FUT-017). + +**Deferred** — to v4.3 and later, pending the maintainer's decision: a queue-position and fill-probability model; a strategy-facing exercise instruction; settlement-date accounting; richer contract terms; margin and forced liquidation. + +--- + +# Alternatives considered + +* **Recording the observation sets on `RunState`.** The honest place for them, + and the reason it was not taken: a new field moves every run's `result_id`, + including runs that read no observations, which the v4 policy reserves for a + schema step and a migration row (FUT-005). +* **Recording consumption counters in the replay payload.** Unnecessary: + what a run has applied follows from the last record it was handed, so the + payload carries only the schedule's digest (decision 7). +* **Auto-settling an expired contract at its last mark.** A default price for + a settlement nobody published; refused instead (decision 10). +* **Each child an OMS order of its own.** Every child would be judged by risk, + reserve capital and carry contributions separately, so one decision would be + booked as many and a cancelled child would release what its parent still + committed. The live path settles children on the parent for the same reason + (decision 13). +* **Inferring a contract's terms from its market.** "American and physical" + is one market's equity-option convention and wrong for its index options and + for most of the world's; terms are declared or the event is refused + (decision 14). +* **Drawing assignments.** A probabilistic assignment model is a research + assumption with a random state, not an engine fact; assignment before expiry + is a supplied input (decision 14). +* **Filling an order the algorithm cannot work whole, with a note.** A backtest + that substitutes an immediate fill reports an execution nobody asked for; an + unworkable parent is rejected, with the reason, instead. +* **Aligning combined datasets to a common clock.** Forward-filling an + asynchronous series is the look-ahead and staleness error the point-in-time + work exists to prevent; the run's slices already say when an instant is + complete. +* **Calling it v4.1.1.** The brief asked for that number and also forbade + mislabelling a feature release as a patch; the additive public API decides it. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 06633c30..92c2f83b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -450,6 +450,15 @@ fill back (`apply_broker_execution`), and the return leg goes through `ExecutionPipeline.apply_execution_report` — the same function a simulated fill uses. +A historical run may instead route `ExecutionRouting.ALGORITHMIC` (v4.1.1, +FUT-009): every accepted order is a parent held in the OMS, as under `EXTERNAL`, +and an `ExecutionPlan`'s algorithms -- driver state in +`alphalab.backtesting.algorithmic`, beside the run -- work it in children at its +asset's events. Each child is simulated by `ExecutionPipeline.work_child` with +the run's own fill policy and cost model and settles on the parent through the +same report path, so the OMS, the portfolio, the reservation and attribution see +one order, as they do for a live parent worked by `route_child_order`. + Two pre-trade gates: an order is never sent on a connection that is not `CONNECTED`, and an OMS order already bound to a venue handle is never sent again. The client order id is *derived* from the OMS order id, so a retry after @@ -515,7 +524,7 @@ schema is unchanged. | **Point-in-time ingestion** | **Implemented (v3.7).** `alphalab.api.ingest_observations` / `ingest_events` / `ingest_fundamentals` with an explicit availability rule and a declared timestamp reading; `lift_wire_records` with the wire timestamp's meaning declared | | **Knowledge frames, event studies, fundamentals, regimes** | **Implemented (v3.7).** `factor_library.knowledge` and `factor_library.fundamentals`; `research.event_study` (anchored where news could be traded, no p-value); `alt_data.fundamentals` (TTM, valuation, ratios, restatements at an instant); `research.regimes` (declared rules, persistence, a reconstructable state) | | **Adaptive strategies** | **Implemented (v3.7).** `strategy.adaptive`, `strategy.adaptive_rules`, `strategy.adaptive_strategy` — immutable learned state with lineage, one pure update function, checkpoints and reprocessing; the state reaches the run snapshot and `digest_run`; `lifecycle.assess_adaptive_replay` | -| **Execution-path delivery of external information** | **Implemented (v3.12).** An observation is delivered at the instant it became knowable to the strategies subscribed to `observations` or `observations:` that define `on_observation`: `ExecutionPipeline.process_observation`, `RunEngine.deliver_observation` (an idempotent cursor) and `BacktestEngine.run(observations=)`, which merges them with the records; an order an observation causes rests to the next market event (OFE-009, ADR-0047). ADR-0042 had deferred it; this row said so until v3.13 | +| **Execution-path delivery of external information** | **Implemented (v3.12).** An observation is delivered at the instant it became knowable to the strategies subscribed to `observations` or `observations:` that define `on_observation`: `ExecutionPipeline.process_observation`, `RunEngine.deliver_observation` (an idempotent cursor) and `BacktestEngine.run(observations=)`, which merges them with the records; an order an observation causes rests to the next market event (OFE-009, ADR-0047). ADR-0042 had deferred it; this row said so until v3.13. Since the v4.1.1 candidate every timed input -- observations, rates, funding, cash flows, per-unit distributions, funding rates, splits, timers, contract settlements -- is a `RunSchedule` (`alphalab.backtesting.schedule`) that `BacktestEngine.run`, `ReplayBacktest.run` and the facade apply through one `ScheduleCursor`; a paused replay's payload records the schedule's digest (replay schema 2); `LiveSession.deliver_observation` delivers and routes (OFE-027, FUT-003, FUT-004, ADR-0051) | | Vendor alternative-data, event or fundamentals feeds | **None.** AlphaLab ships the point-in-time contract and ingests rows a caller supplies, with their bytes; it fetches nothing | | **The risk model** | **Implemented (v3.8).** `alphalab.analytics.risk_model` — `CovarianceMatrix` with currency, period, source, observations and a derived identity; definiteness measured by a rank-revealing Cholesky; ridge and diagonal shrinkage as recorded derivations; `FactorLoadings` and `Classification` that refuse holes. v3.3's decomposition calls the same arithmetic, unchanged bit for bit | | **Constrained portfolio construction** | **Implemented (v3.8).** `alphalab.portfolio_optimizer.construct` — minimum variance, mean-variance, maximum diversification, risk parity (equal or stated budgets) and robust mean-variance under bounds, concentration, gross, group, factor, turnover, notional and volatility constraints; one dual active-set solver certifying optimality and naming conflicts; `black_litterman` for a posterior. The v1 closed forms are unchanged. Since v3.12 a factor-model covariance over a large universe is solved by an O(n k²) interior point certified by the same criteria, the dense solver deciding whatever it does not certify (PRF-005) | @@ -683,11 +692,11 @@ owning module, its own schema constant and its own typed decoder: | `AllocationState` | `allocation.snapshot` | `ALLOCATION_SNAPSHOT_SCHEMA = 3` | v2.9 | | `ExecutionPipelineState` | `runtime.snapshot` | `PIPELINE_SNAPSHOT_SCHEMA = 8` | v2.9 | | `RunState` | `runtime.run_snapshot` | `RUN_SNAPSHOT_SCHEMA = 5` | v2.14 | -| `InstrumentRegistry` | `instrument.snapshot` | `INSTRUMENT_SNAPSHOT_SCHEMA = 3` | v2.15 | +| `InstrumentRegistry` | `instrument.snapshot` | `INSTRUMENT_SNAPSHOT_SCHEMA = 4` | v2.15 | | `BrokerState` | `broker.snapshot` | `BROKER_SNAPSHOT_SCHEMA = 2` | v2.16 | | `LiveRunState` | `runtime.live_snapshot` | `LIVE_SNAPSHOT_SCHEMA = 2` | v2.16 | | `FxFeedState` | `portfolio.fx_feed` | `FX_FEED_SNAPSHOT_SCHEMA = 1` | v2.17 | -| `PausedReplay` | `backtesting.replay_snapshot` | `REPLAY_SNAPSHOT_SCHEMA = 1` | v4.1 | +| `PausedReplay` | `backtesting.replay_snapshot` | `REPLAY_SNAPSHOT_SCHEMA = 2` | v4.1 | Three envelopes carry a payload and version only what they add: the run-state store's (`persistence.run_state.RUN_STATE_ENVELOPE_SCHEMA = 1`, v2.14), an diff --git a/docs/COMPOSING_STRATEGIES.md b/docs/COMPOSING_STRATEGIES.md new file mode 100644 index 00000000..f49e69fa --- /dev/null +++ b/docs/COMPOSING_STRATEGIES.md @@ -0,0 +1,265 @@ +# Composing a Strategy from Heterogeneous Inputs + +This guide is for a researcher with an idea AlphaLab's authors did not +anticipate. It says which contracts a strategy is written against, how prices +from several sources, external information and models reach it, what the engine +guarantees about time, and where the boundaries are. Everything below is current +behaviour, checked by tests; the worked example is +[`examples/72_compose_a_multi_asset_strategy.py`](../examples/72_compose_a_multi_asset_strategy.py), +held to its properties by `tests/integration/test_multi_asset_composition.py`. +Start with [`GETTING_STARTED.md`](GETTING_STARTED.md) (example 70) if you have +never written one. + +## 1. There is no list of strategy types + +A strategy is any object satisfying `alphalab.strategy.StrategyProtocol` -- +usually a subclass of `BaseStrategy` overriding the hooks it needs. A +`StrategyClassRegistry` maps a strategy's *identity* to a factory that builds it +from its declared parameters; it versions and discovers your code, and it +places no limit on what that code does. Nothing in AlphaLab enumerates strategy +families, and a new idea needs no change to the engine. + +| Hook | Called with | When | +| --- | --- | --- | +| `on_bar`, `on_quote`, `on_trade`, `on_tick` | one market record | each subscribed record, as it is published | +| `on_slice` | `SliceClosed(timestamp, assets)` | once every record of an instant is in -- the one moment a cross-sectional decision sees every price of that instant | +| `on_observation` | `ObservationReceived(record, delivery_id, subject)` | when a point-in-time record becomes knowable | +| `on_timer` | `TimerEvent` | when the driver fires a timer | +| `on_fill`, `on_order` | the strategy's own fills and order changes | after the step that produced them | +| `on_start`, `on_shutdown`, `on_stop` | -- | lifecycle | + +Subscriptions decide what is delivered: `bars`, `quotes`, `slices`, +`observations` (or `observations:`), `timers`, `*`. A hook returns +`Intent`s -- `DELTA`, `TARGET_QUANTITY` or `TARGET_WEIGHT`, with `OrderTerms` +(market, limit, stop, opening or closing auction). That is the only way a +strategy affects anything: allocation nets intents, risk gates the requests, +the OMS works them and the portfolio books the fills, exactly as for every +other strategy. + +## 2. Several assets, several datasets + +* **Declare every instrument.** `InstrumentRecord` gives it a derived `asset_id`; + a future or option must declare `InstrumentEconomics` (multiplier, settlement + model, lot), or it cannot be booked. A strategy reads the declaration from + `context.universe.instrument(asset_id)` instead of assuming one. +* **One dataset per source and series kind.** Ingest each file with + `alphalab.api.ingest_csv` or `ingest_rows`; it gets a version derived from its + bytes. Read several in one run with `alphalab.api.combine_datasets` and + `combined_market_dataset`: the run's dataset identity is derived from the + parts, in order, and `lifecycle.manifest_for_run` checks it. Nothing is + aligned to a common clock or forward-filled; each series keeps its own + instants, and one instrument's price from two parts at one instant is refused. +* **Read prices from the context.** `context.market.price(asset_id)` is the + marking price, `context.market.bar(asset_id)` the latest bar (corrected in + v4.1.1: until then it returned `None`), and `context.history.bars(asset_id, + limit)` the bars up to the event, never after. +* **Missing data is yours to treat.** A missing bar is a missing record; compare + a bar's timestamp with the decision instant and decide, as example 72's hedge + does, rather than trading on a stale price. + +## 3. External information, at the instant it was knowable + +Sentiment scores, news events, statement figures, macro releases, satellite or +web readings: each is an `ExternalObservation`, `InformationEvent` or +`FundamentalObservation` with a `PointInTimeStamp` that keeps four instants +apart -- observed (what the figure is about), effective, **available** (when +anyone could have known it) and ingested. Ingest rows with +`alphalab.api.ingest_observations` / `ingest_events` / `ingest_fundamentals`, +stating how availability is known: + +| Rule | Meaning | +| --- | --- | +| `AvailabilityFromColumn("collected_at")` | each row states it | +| `AvailabilityAfterLag(seconds)` | a documented processing delay | +| `AvailabilityAtNextOpen(column, calendar)` | a date-only publication, placed at the next session open | +| `AvailabilityNotDeclared()` | the source does not say: every row is `UNKNOWN` and never delivered | + +For a macro series the **period measured** (`ReferencePeriod`), the **release +instant** (availability) and the **vintage** (`revision`) are three columns and +stay three fields. A repeated row and an unreadable row are rejected with their +line numbers; a revision published ahead of the figure it revises is refused. + +`alphalab.alt_data.delivery_schedule(set, VisibilityRule.PUBLICATION)` orders a +set's records by when each became knowable. A macro series kept in +`alphalab.macro` is lifted with `alphalab.api.observations_from_indicator` +(observed at its reference period, known at its release, the vintage numbered by +release order). Put the deliveries -- and every other timed input, section 4 -- +in one `RunSchedule` and give it to the run: + +```python +schedule = RunSchedule.of(observations=deliveries) +result = BacktestEngine.run(config, market, strategies, contexts, schedule=schedule) +replay = ReplayBacktest.run(config, market, strategies, contexts, schedule=schedule) +``` + +or through the facade, `alphalab.api.backtest(..., schedule=...)` and `replay`. +Every driver applies one rule: a record is delivered at its knowledge instant, after +every market record of that instant and before its slice closes; what a +strategy asks for in response rests until each asset's next event, so it never +fills at a price printed before the information existed; a record knowable after +the data ends is not delivered. A live session delivers with +`LiveSession.deliver_observation(..., now=...)`, never before the record could +have been known, and routes what it produced. + +**What the engine guarantees, and how it is tested.** Example 72's fixture holds +a post written on 8 April and only collected on 22 April, and a CPI revision +published two weeks after the first release. The test proves from the +strategy's own log that no decision saw either early; that changing both values +changes nothing before their availability; and that a schedule built on the +*observation* instant -- the naive join -- moves decisions two weeks early and is +caught by the same check. + +## 4. Money, time and contracts: the run's schedule + +`alphalab.backtesting.schedule.RunSchedule.of` takes every input that happens at +an instant rather than arriving as a market record, and every historical driver +applies it through one `ScheduleCursor`: + +| Input | Type | Applied | +| --- | --- | --- | +| FX rates | `FxRate` | before the records of their instant; every later step converts at the rate in force, and a mixed-currency book without one is refused (`MissingRateError`) | +| Deposits and withdrawals | `Funding` | before the records of their instant | +| Stated cash flows | `ScheduledCashFlow` | before; the amount is the total | +| Dividends, coupons, distributions | `ScheduledDistribution` | before; per unit, times whatever is held then -- a short pays | +| Perpetual funding | `ScheduledFundingRate` | before; `-(quantity x multiplier x mark x rate)`, refused without a mark | +| Splits | `ScheduledSplit` | before; working orders are cancelled, as for any split | +| Timers | `TimerEvent` | after the records of their instant, to `on_timer` | +| Contract settlement | `ScheduledSettlement` | after; see below | + +Two entries naming one thing at one instant are refused. A future or option +declares `last_trade_at` on its `InstrumentRecord`: records after it are +skipped, requests after it are refused before an order exists, and a +`ContractSettlement(asset_id, price)` closes every position at the final price +(a future's last variation, an option's intrinsic value, zero for one expiring +worthless), cancelling working orders and attributing the result to the +strategies that held it. A run that ends holding an expired contract is refused. +A fixed-rate bond (`AssetType.BOND`) trades at full prices with its maturity as +`last_trade_at`; `alphalab.api.bond_schedule` turns a `macro.Bond`'s terms into +its coupons and redemption. + +A contract can also declare what it **does** -- `InstrumentRecord.terms`: + +```python +call = InstrumentRecord( + "WIDGETC100", + AssetType.OPTION, + "XEXM", + "GBP", + economics=InstrumentEconomics(Decimal("100"), SettlementModel.OPTION_PREMIUM), + last_trade_at=session_close(market, expiry_day), + terms=OptionTerms( + OptionType.CALL, + Decimal("100"), + ExerciseStyle.AMERICAN, + SettlementStyle.PHYSICAL, + Deliverable(widget.asset_id, Decimal("100")), + expires_at=session_close(market, expiry_day), + automatic_exercise=AutomaticExercise(Decimal("0.01")), + ), +) +``` + +Then `Exercise`, `Assignment` and `OptionExpiry` on the schedule +(`ScheduledExercise`, `ScheduledAssignment`, `ScheduledExpiry`) carry out its +life: inside its window -- any instant for an American contract, from the last +trade to expiry for a European one -- a cash-settled contract pays its intrinsic +value against the settlement value you supply, a physical one delivers its +deliverable against the strike and the shares are marked at the market's price, +and at expiry the declared automatic-exercise rule exercises longs and assigns +shorts. Assignment before expiry is an input you state, never drawn. A future's +`FutureTerms` may deliver too. Nothing is inferred from a market's name: its +calendar (`MarketCalendar`) and `SettlementRule` give every instant through +`alphalab.runtime.session_close` and `settles_at`, so another market is another +declaration (example 75). A delivery the book cannot pay for or would go short +on without permission is refused; baskets, cash in lieu, quanto and Bermudan +contracts cannot be stated yet (FUT-016), and cash moves at the event's instant, +not a settlement date (FUT-015). + +## 5. How the orders are executed + +A strategy decides what to own; how its orders trade is a separate choice, and a +backtest can measure it. Give `BacktestEngine.run`, `ReplayBacktest.run` or +`alphalab.api.backtest` an `ExecutionPlan`: + +```python +plan = ExecutionPlan(AlgorithmAssignment(TWAP(6, Urgency.neutral()), 1800.0, Decimal("1"))) +result = BacktestEngine.run(config, market, strategies, contexts, execution=plan) +``` + +Every order is then a **parent**, accepted and held by the OMS -- risk judges it +whole and allocation reserves it -- and worked in **children** at its asset's +events by the assigned algorithm (TWAP, VWAP, participation, slicing, iceberg; +`by_asset` assigns per instrument). Each child is filled by the run's own fill +policy and cost model, so a liquidity cap applies to each child and every fill +pays the spread, impact and commission the run states; the fill settles on the +parent, so attribution, the reservation and the books see one order. Under +`FillTiming.NEXT_EVENT` a child trades only at a later instant than it was +released, and never after its slice or window; a parent no algorithm can work -- +a stop, an auction, a quantity off the increment, an asset with no assignment -- +is refused or rejected, never filled whole. The result is an +`AlgorithmicResult`, whose `algorithms` holds every +parent, child and fill; `alphalab.execution.quality.implementation_shortfall` +measures them (example 74). A `VenueCheck` on the plan checks every child against +a venue's declared capabilities. A child's fill reaches its strategy through the +portfolio its next context shows, not `on_fill`. + +## 6. Features, factors and models + +A strategy computes its features in its hooks, from what it has been shown. Keep +the inputs a feature needs in the strategy's memory and declare that memory +(section 7). Research-side features (`alphalab.factor_library`, +`alphalab.api.run_study`) and validation (`alphalab.research`: walk-forward, +purged and embargoed time-series cross-validation, deflated Sharpe, multiple +testing, regimes, event studies) measure a signal before it trades. Evaluate the +study's own `FeatureDefinition`s in the strategy with `compute_feature` over the +history it has been shown, as +[`examples/73_train_and_trade_a_model.py`](../examples/73_train_and_trade_a_model.py) +does: its test holds the traded values equal to the study's at every instant +(each evaluation recomputes the window; there is no incremental evaluator). A +model is any object the strategy holds: fit it only on samples whose label was +known before the decision boundary (example 73 purges the rest), declare its +fitted parameters as the strategy's parameters so the fingerprint hashes them, +capture them as state, and seed anything stochastic. An adaptive +strategy (`alphalab.strategy.AdaptiveStrategy`) learns with a hash-chained, +replayable state. Portfolio construction (`alphalab.portfolio_optimizer.construct`, +Black-Litterman, risk parity) is a library a strategy calls and turns into +`TARGET_WEIGHT` or `TARGET_QUANTITY` intents (example 10). + +## 7. State, reruns, replay and continuation + +Implement `StrategyStateProtocol` (`strategy_state_version`, `capture_state`, +`restore_state`) for every piece of memory a decision reads. Then: + +* a seeded run is reproduced exactly -- `lifecycle.digest_run(result).result_id` + is the same in this process and in a fresh interpreter; +* a replay equals the backtest, and a replay paused at any boundary -- with + information, rates or settlements still to come -- written to JSON and + restored with fresh objects resumes into the same result; the payload records + the schedule's digest, and restoring it with another schedule, or none, is + refused; +* a backtest stopped at any record, written to JSON, restored with freshly built + objects and continued with `ScheduleCursor.start(schedule, )` finishes byte-identical: what remains is derived from + that record, so nothing is applied twice or lost. + +For lineage, `lifecycle.manifest_for_run(..., schedule=schedule)` checks the +schedule holds the run's deliveries and records its digest in the manifest's +identity. + +## 8. Boundaries + +* AlphaLab ships no data, no vendor client, no broker adapter, no holiday + calendar and no FX rates; each is the host's (`docs/INTEGRATION.md`). +* Strategy code runs in the caller's process with the caller's privileges. A + hosted application running other people's strategies isolates them itself. +* No text-to-sentiment model, no LLM and no network access are in the engine. +* FX rates are inputs the host supplies; the engine converts with them and + never invents one. +* A backtest simulates one venue and one cost model: choosing between venues + (`alphalab.execution.routing.select_route`) is evaluated on the live and paper + path, not historically. +* Depth updates reach no strategy hook (BDY-003); passive fills have no queue + model (FUT-011). + +See [`docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md`](audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md) +for what each strategy family needs and where it stands. diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 5ad9ff78..ddd7751c 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -21,7 +21,7 @@ Examples are intended to be read sequentially by new users and used as reference > broker boundary, having used `alphalab.integrations` until that package was > removed. `70_build_a_strategy.py` (v4.0) builds a strategy from data to a > reproduced result and is checked line by line by an end-to-end test. None of -> the others are part of the automated test suite, though all seventy-one run as a +> the others are part of the automated test suite, though all seventy-five run as a > release gate. > For the integrated market-to-analytics path see > `alphalab.backtesting`, `alphalab.runtime.ExecutionPipeline`, and their tests @@ -53,6 +53,10 @@ The `examples/` directory contains: | `16_research_from_dataset.py` | Research access by dataset version, point-in-time selection, and a backtest that names the exact bytes it read | | `70_build_a_strategy.py` | **Build your first strategy**: data → strategy → run → fills checked by hand → a stopped run continued to the same result → refusals | | `71_pause_and_resume_a_replay.py` | **Pause a replay, keep it, resume it** (v4.1): a replay paused between records, kept in a file store, restored with fresh objects and resumed to the uninterrupted `result_id` | +| `72_compose_a_multi_asset_strategy.py` | **Compose a multi-asset strategy** (v4.1.1): two price datasets, sentiment and macro releases at their availability, a researcher's strategy with a futures hedge, costs, risk refusals, lineage, replay and continuation | +| `73_train_and_trade_a_model.py` | **Train a model on the past and trade it forward** (v4.1.1): features defined once, a model fitted before a boundary, the same features and model on the execution path | +| `74_evaluate_an_execution_algorithm.py` | **Evaluate an execution algorithm in a backtest** (v4.1.1): one decision executed whole and by TWAP, VWAP, participation and iceberg over the same bars, each child through the run's liquidity cap and cost model, measured as implementation shortfall; replayed and resumed | +| `75_exercise_assign_and_deliver.py` | **Exercise, assign and deliver** (v4.1.1): an American physically settled call exercised early before a dividend by a stated policy, a European cash-settled put on an index settled at expiry, every instant from a declared market's calendar; refusals; replayed and resumed | --- @@ -388,6 +392,45 @@ result. It assumes nothing; read it first if you are here to write a strategy. `docs/INTEGRATION.md` states the contract it exercises; the installed smoke test runs it from every built wheel and sdist. +# Composing a strategy from heterogeneous inputs (v4.1.1) + +| # | Shows | +|---|---| +| 72 | An equity on New York's sessions and an index future settled an hour later, ingested as two datasets and read as one (`combine_datasets`); posts scored for sentiment and a US CPI series, ingested with the column that says when each row became available and delivered to the strategy at that instant -- one post written on 8 April and collected on 22 April, March's figure revised on 24 April, May's published after the data ends, a post with no stated availability counted and never delivered, a repeated and an unreadable row rejected by line; `CompositeSignal`, written against the protocol, combining five-session momentum, decayed sentiment and the latest-known inflation vintage into an equity position and a futures hedge sized with the contract's declared multiplier, leaving the future alone the day its bar is missing; orders at the next open with a stated cost model and a 10% participation cap; twelve orders refused by the risk gate and reported; the result's manifest over the combined dataset and a fingerprint naming the information sets; the run reproduced, replayed, paused and resumed, and stored and continued to the same `result_id`; and the late values rewritten, with the first changed decision after they became knowable | + +`docs/COMPOSING_STRATEGIES.md` explains the contracts it +uses; `tests/integration/test_multi_asset_composition.py` holds it to eleven +properties, including a leaking control that the same check catches. Nothing in +it says sentiment or inflation predicts returns: the prices are synthetic. + +| # | Shows | +|---|---| +| 73 | A synthetic close series; `mom_5` and `z_10` defined once as `FeatureDefinition`s and computed by the study path; a ridge-regularized linear regression fitted on every sample whose next-session label was knowable before session 80, the sample whose label reaches across it purged; the same definitions evaluated by the strategy on the closes it has been shown, equal to the study's at all 111 instants; the coefficients as the strategy's declared parameters (so its fingerprint hashes them) and its state (restored exactly, refused if another model is built); orders from session 80 on; prices from the boundary on rewritten, changing neither the model nor any earlier decision; the run reproduced | + +`tests/integration/test_model_on_the_execution_path.py` holds it. + +# Evaluating an execution algorithm in a backtest (v4.1.1) + +| # | Shows | +|---|---| +| 74 | One hour of synthetic minute bars with a U-shaped volume; one decision to buy 3,000 shares executed whole -- cut to a quarter of the next bar by the liquidity cap, the rest withdrawn -- and by TWAP, VWAP over a stated profile, participation at a tenth of printed volume, and an iceberg of 400, each child released at the bars, filled under the same cap and costed by spread, square-root impact and a commission per fill, and settled on one parent order; each run's implementation shortfall against the open; the TWAP replayed, paused with a child working, written to JSON and resumed to the same replay | + +`tests/integration/test_execution_algorithm_example.py` holds it to its figures, +and `tests/integration/test_algorithms_on_the_historical_path.py` holds the +mechanism. The bars are synthetic and the profile a stated shape: nothing in it +says one algorithm is better than another in a real market. + +# An option's whole life in a backtest (v4.1.1) + +| # | Shows | +|---|---| +| 75 | A market declared by its calendar -- Europe/London's zone, weekday sessions, a holiday -- and every instant read from it: expiry at its close, a dividend at an ex-date's open. A researcher's strategy buys two American, physically settled calls when momentum first turns up, and a European, cash-settled put on an index; a stated policy exercises one call at the close before the ex-dividend date, so 100 shares are delivered against the strike and receive the dividend; at expiry the other call is exercised automatically and the put settles in cash against the index's value. A Bermudan style, exercise ending before trading, and a European put exercised early are refused. The run replayed, paused before the exercise, written to JSON and resumed | + +`tests/integration/test_lifecycle_example.py` holds it figure for figure; +`tests/integration/test_option_lifecycle.py` and +`test_lifecycle_conventions.py` hold the mechanism and the conventions. The +prices are synthetic and the market is a stated one. + --- # Additional engines diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 754a9276..a0de1287 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -82,7 +82,7 @@ deployment reproducible, and is what this release recommends. **The public API.** Every name in `docs/api/public_api.json`: each package's `__all__`, and the `__all__` of `alphalab.api` and of each durable state's -snapshot module -- 2,628 names. `tests/regression/test_public_api_manifest.py` +snapshot module -- 2,667 names. `tests/regression/test_public_api_manifest.py` fails on any name added, removed or re-signed without the manifest changing, and each release's CHANGELOG names every removal or rebinding. Since v4.1 one signature reads the same on every supported interpreter (API-008). A name with a diff --git a/docs/README.md b/docs/README.md index 031288a4..d134c867 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,6 +49,7 @@ Reference | Document | Description | |-----------|-------------| | `GETTING_STARTED.md` | Installation, the repository, and building, checking and reproducing a first strategy | +| `COMPOSING_STRATEGIES.md` | Writing a strategy over several assets, datasets, external information and models: the contracts, the time guarantees and the boundaries (v4.1.1) | | `EXAMPLES.md` | End-to-end examples covering research, backtesting, portfolio optimization, and production workflows | | `api/PUBLIC_API.md` | The public API, its manifest, and the stability policy from v4.0 | | `INTEGRATION.md` | Depending on AlphaLab from an application: how to install a release, the tested Python, and what an application may rely on | diff --git a/docs/STATE_MODEL.md b/docs/STATE_MODEL.md index c9ee77cd..8071a111 100644 --- a/docs/STATE_MODEL.md +++ b/docs/STATE_MODEL.md @@ -192,17 +192,30 @@ and a typed decoder that refuses what it does not understand. Eleven do (v4.1): | `AllocationState` | `allocation.snapshot` | `ALLOCATION_SNAPSHOT_SCHEMA` | 3 | | `ExecutionPipelineState` | `runtime.snapshot` | `PIPELINE_SNAPSHOT_SCHEMA` | 8 | | `RunState` | `runtime.run_snapshot` | `RUN_SNAPSHOT_SCHEMA` | 5 | -| `InstrumentRegistry` | `instrument.snapshot` | `INSTRUMENT_SNAPSHOT_SCHEMA` | 3 | +| `InstrumentRegistry` | `instrument.snapshot` | `INSTRUMENT_SNAPSHOT_SCHEMA` | 4 | | `BrokerState` | `broker.snapshot` | `BROKER_SNAPSHOT_SCHEMA` | 2 | | `LiveRunState` | `runtime.live_snapshot` | `LIVE_SNAPSHOT_SCHEMA` | 2 | | `FxFeedState` | `portfolio.fx_feed` | `FX_FEED_SNAPSHOT_SCHEMA` | 1 | -| `PausedReplay` | `backtesting.replay_snapshot` | `REPLAY_SNAPSHOT_SCHEMA` | 1 | +| `PausedReplay` | `backtesting.replay_snapshot` | `REPLAY_SNAPSHOT_SCHEMA` | 2 | + +`InstrumentRegistry` schema 4 (v4.1.1) carries each instrument's last trading +instant (FUT-012) and its contract terms (FUT-013) -- `OptionTerms` or +`FutureTerms`, enum members by name and amounts as text -- read back through +the record's own validation, so a payload stating terms a contract cannot have +is refused. Neither enters the identity key: every `asset_id` is what it was. `PausedReplay` (v4.1, ledger FUT-001) is a replay stopped between two records: its envelope nests the run snapshot unchanged and adds the replay cursor and the position of the cursor's own identifier stream. Its `restore` also takes the dataset back -- the replay's input, recorded by id, length, the ids at the -boundary and a digest of every record, and refused when it differs. +boundary and a digest of every record, and refused when it differs. Since +v4.1.1 (schema 2) it also records its schedule's digest and, for a replay given +an execution plan (ledger FUT-009), the plan's algorithm book, written by +`backtesting.algorithmic.capture_book` (`ALGORITHM_BOOK_SCHEMA = 1`): every +parent's algorithm with its children, and every child fill. The plan is taken +back by `restore` and held to the book by its `plan_id`, and each algorithm run +is rebuilt and checked against its recorded identity. The book is driver state, +not part of `RunState`, so no run's record or `result_id` moved. Three envelopes version only what they add around a payload: `RUN_STATE_ENVELOPE_SCHEMA` (the run-state store, 1), `CHECKPOINT_SCHEMA` (an diff --git a/docs/api/PUBLIC_API.md b/docs/api/PUBLIC_API.md index 2df44896..58c20b44 100644 --- a/docs/api/PUBLIC_API.md +++ b/docs/api/PUBLIC_API.md @@ -23,7 +23,7 @@ durability table. | [`generate_public_api.py`](generate_public_api.py) | Writes the manifest from the package. Keeps the reasons already written; leaves a new shared name's reason empty | | `tests/regression/test_public_api_manifest.py` | Fails on any export added, removed or rebound, on a shared name without a reason, and on a manifest for another release | -At v4.1.0 the manifest records 44 packages and 12 documented modules, 2,628 exports and 35 shared names +At v4.1.0 the manifest records 44 packages and 12 documented modules, 2,667 exports and 35 shared names (`test_public_api_manifest.py` holds this sentence to the manifest). ## Stability from v4.0 diff --git a/docs/api/public_api.json b/docs/api/public_api.json index b4c35580..622c5a3f 100644 --- a/docs/api/public_api.json +++ b/docs/api/public_api.json @@ -312,6 +312,9 @@ "AvailabilityFromColumn": "class alphalab.api.AvailabilityFromColumn(column: 'str') -> None", "AvailabilityNotDeclared": "class alphalab.api.AvailabilityNotDeclared() -> None", "AvailabilityRule": "alias AvailabilityRule", + "CombinedDataset": "class alphalab.api.CombinedDataset(parts: 'tuple[tuple[Dataset, NormalizationPolicy], ...]') -> None", + "CombinedProvenance": "class alphalab.api.CombinedProvenance(dataset_version: 'str', content_hash: 'str', source: 'CombinedSourceBytes', parts: 'tuple[tuple[str, str], ...]') -> None", + "CombinedSourceBytes": "class alphalab.api.CombinedSourceBytes(byte_count: 'int') -> None", "DataRequest": "class alphalab.api.DataRequest(symbols: 'tuple[str, ...]' = (), start: 'float | None' = None, end: 'float | None' = None, price_basis: 'PriceBasis | None' = None, as_of: 'float | None' = None) -> None", "DataSelection": "class alphalab.api.DataSelection(records: 'tuple[CanonicalRecord, ...]', dataset_version: 'str | None', request: 'DataRequest') -> None", "EventColumns": "class alphalab.api.EventColumns(subject: 'str', event_type: 'str', observed_at: 'str', availability: 'AvailabilityRule', measurements: 'tuple[str, ...]', attributes: 'tuple[str, ...]', effective_at: 'str | None', revision: 'str | None', ingested_at_retrieval: 'bool') -> None", @@ -323,8 +326,11 @@ "StudyResult": "class alphalab.research.study.StudyResult(result_id: 'str', study: 'ResearchStudy', metrics: 'Mapping[str, float]' = , splits: 'SplitReport | None' = None, feature_lineage: 'Mapping[str, str]' = , findings: 'tuple[str, ...]' = , warnings: 'tuple[str, ...]' = , produced_at: 'float' = 0.0) -> None", "TimestampReading": "class alphalab.api.TimestampReading(timestamp_format: 'TimestampFormat', timezone_name: 'str | None', date_policy: 'DateOnlyPolicy | None') -> None", "WireTimestamp": "enum alphalab.api.WireTimestamp[AVAILABILITY, OBSERVATION]", - "backtest": "function alphalab.api.backtest(config: 'RunConfig', dataset: 'Dataset', strategy_state: 'StrategyRuntimeState', context_factory: 'ContextFactory', policy: 'NormalizationPolicy') -> 'BacktestResult'", + "backtest": "function alphalab.api.backtest(config: 'RunConfig', dataset: 'Dataset', strategy_state: 'StrategyRuntimeState', context_factory: 'ContextFactory', policy: 'NormalizationPolicy', *, observations: 'Iterable[ObservationDelivery[Any]]' = (), schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), execution: 'ExecutionPlan | None' = None) -> 'BacktestResult'", + "bond_schedule": "function alphalab.api.bond_schedule(bond: 'Bond', instrument: 'InstrumentRecord', at: 'Callable[[date], float]', *, redemption_price: 'Decimal') -> 'RunSchedule'", "clean_dataset": "function alphalab.api.clean_dataset(dataset: 'Dataset', policy: 'CleaningPolicy') -> 'tuple[tuple[CanonicalRecord, ...], DataQualityReport]'", + "combine_datasets": "function alphalab.api.combine_datasets(parts: 'Sequence[tuple[Dataset, NormalizationPolicy]]') -> 'CombinedDataset'", + "combined_market_dataset": "function alphalab.api.combined_market_dataset(combined: 'CombinedDataset') -> 'MarketDataset'", "convention_from_spec": "function alphalab.api.convention_from_spec(spec: 'EquitySpec | FutureSpec | OptionSpec', calendar_id: 'str', tick: 'TickSchedule', lot: 'LotSpecification', settlement: 'SettlementRule', settlement_currency: 'str | None' = None) -> 'MarketConvention'", "currency_classification": "function alphalab.api.currency_classification(registry: 'InstrumentRegistry', asset_ids: 'Sequence[str]') -> 'Classification'", "future_contract_from_spec": "function alphalab.api.future_contract_from_spec(spec: 'FutureSpec', currency: 'str | None' = None) -> 'FutureContract'", @@ -337,9 +343,10 @@ "lift_wire_records": "function alphalab.api.lift_wire_records(records: 'Sequence[CanonicalRecord]', name: 'str', source: 'ObservationSource', meaning: 'WireTimestamp', unit: 'str') -> 'ObservationSet[ExternalObservation]'", "normalize_records": "function alphalab.api.normalize_records(records: 'Sequence[CanonicalRecord]', policy: 'NormalizationPolicy') -> 'tuple[MarketInput, ...]'", "observation_source": "function alphalab.api.observation_source(raw: 'RawSource', source_id: 'str', version: 'str | None', quality: 'DataProvenance | None' = None) -> 'ObservationSource'", + "observations_from_indicator": "function alphalab.api.observations_from_indicator(metadata: 'IndicatorMetadata', releases: 'Sequence[IndicatorObservation]', source: 'ObservationSource', *, name: 'str | None' = None) -> 'ObservationSet[ExternalObservation]'", "observe": "function alphalab.api.observe(dataset: 'Dataset', source_field: 'FeatureField') -> 'ObservationFrame'", "option_contract_from_spec": "function alphalab.api.option_contract_from_spec(spec: 'OptionSpec') -> 'OptionContract'", - "replay": "function alphalab.api.replay(config: 'RunConfig', dataset: 'Dataset', strategy_state: 'StrategyRuntimeState', context_factory: 'ContextFactory', policy: 'NormalizationPolicy', *, pause_when: 'Callable[[ReplayProgress], bool] | None' = None) -> 'ReplayResult | PausedReplay'", + "replay": "function alphalab.api.replay(config: 'RunConfig', dataset: 'Dataset', strategy_state: 'StrategyRuntimeState', context_factory: 'ContextFactory', policy: 'NormalizationPolicy', *, pause_when: 'Callable[[ReplayProgress], bool] | None' = None, observations: 'Iterable[ObservationDelivery[Any]]' = (), schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), execution: 'ExecutionPlan | None' = None) -> 'ReplayResult | PausedReplay'", "resume_replay": "function alphalab.api.resume_replay(paused: 'PausedReplay', context_factory: 'ContextFactory', *, pause_when: 'Callable[[ReplayProgress], bool] | None' = None) -> 'ReplayResult | PausedReplay'", "run_study": "function alphalab.api.run_study(study: 'ResearchStudy', dataset: 'Dataset', price_field: 'FeatureField' = , buckets: 'int' = 5, minimum_assets: 'int' = 5, produced_at: 'float' = 0.0, *, delistings: 'Sequence[DelistingReturn]') -> 'StudyResult'", "sector_classification": "function alphalab.api.sector_classification(registry: 'InstrumentRegistry', asset_ids: 'Sequence[str]', as_of: 'float | None') -> 'Classification'", @@ -349,30 +356,51 @@ "validate_dataset": "function alphalab.api.validate_dataset(dataset: 'Dataset') -> 'tuple[ValidationFinding, ...]'" }, "alphalab.backtesting": { + "AlgorithmAssignment": "class alphalab.backtesting.algorithmic.AlgorithmAssignment(algorithm: 'ExecutionAlgorithm', window_seconds: 'float', quantity_increment: 'Decimal') -> None", + "AlgorithmBook": "class alphalab.backtesting.algorithmic.AlgorithmBook(plan: 'ExecutionPlan', working: 'PersistentMap[str, AlgorithmState]' = , finished: 'PersistentMap[str, FinishedParent]' = , fills: 'AppendOnlyLog[ChildFill]' = ) -> None", + "AlgorithmicResult": "class alphalab.backtesting.state.AlgorithmicResult(run: 'RunState', algorithms: 'AlgorithmBook') -> None", "BacktestEngine": "class alphalab.backtesting.engine.BacktestEngine()", "BacktestError": "class alphalab.backtesting.exceptions.BacktestError", "BacktestResult": "class alphalab.backtesting.state.BacktestResult(run: 'RunState') -> None", + "ChildFill": "class alphalab.backtesting.algorithmic.ChildFill(child_id: 'str', parent_order_id: 'str', execution_id: 'str', quantity: 'Decimal', price: 'Decimal', commission: 'Decimal', timestamp: 'float') -> None", "DatasetValidationError": "class alphalab.backtesting.exceptions.DatasetValidationError", + "EMPTY_SCHEDULE": "value alphalab.backtesting.schedule.RunSchedule", "ExecutionMode": "enum alphalab.runtime.run.ExecutionMode[BACKTEST, REPLAY, PAPER, LIVE]", + "ExecutionPlan": "class alphalab.backtesting.algorithmic.ExecutionPlan(default: 'AlgorithmAssignment | None' = None, by_asset: 'tuple[tuple[str, AlgorithmAssignment], ...]' = (), capabilities: 'VenueCheck | None' = None) -> None", + "ExecutionPlanError": "class alphalab.backtesting.algorithmic.ExecutionPlanError", "FillDecision": "class alphalab.execution.policy.FillDecision(status: alphalab.execution.fill.FillStatus, quantity: decimal.Decimal | None = None) -> None", "FillPolicy": "class alphalab.execution.policy.FillPolicy(*args, **kwargs)", + "FinishedParent": "class alphalab.backtesting.algorithmic.FinishedParent(order_id: 'str', status: 'OrderStatus', at: 'float', reason: 'str', algorithm: 'AlgorithmState | None') -> None", + "Funding": "class alphalab.backtesting.schedule.Funding(amount: 'Decimal', currency: 'str', at: 'float') -> None", "ImmediateFill": "class alphalab.execution.policy.ImmediateFill() -> None", "LiquidityCappedFill": "class alphalab.execution.policy.LiquidityCappedFill(participation_rate: decimal.Decimal = Decimal('1')) -> None", "LiquidityContext": "class alphalab.execution.policy.LiquidityContext(asset_id: str, side: alphalab.core.enums.Side, requested_quantity: decimal.Decimal, price: decimal.Decimal, available_quantity: decimal.Decimal | None, timestamp: float) -> None", "MarketDataset": "class alphalab.backtesting.dataset.MarketDataset(dataset_id: 'str', records: 'tuple[MarketRecord, ...]') -> None", "MarketInput": "alias alphalab.market.quote.Quote | alphalab.market.bar.Bar | alphalab.market.tick.Tick", "MarketRecord": "class alphalab.market.record.MarketRecord(event_id: 'str', timestamp: 'float', payload: 'MarketInput') -> None", - "PausedReplay": "class alphalab.backtesting.replay.PausedReplay(run: 'RunState', cursor: 'ReplayState', cursor_position: 'IdStreamPosition') -> None", + "PausedReplay": "class alphalab.backtesting.replay.PausedReplay(run: 'RunState', cursor: 'ReplayState', cursor_position: 'IdStreamPosition', schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), algorithms: 'AlgorithmBook | None' = None) -> None", "ReplayBacktest": "class alphalab.backtesting.replay.ReplayBacktest()", "ReplayProgress": "class alphalab.backtesting.replay.ReplayProgress(records_replayed: 'int', records_total: 'int', next_timestamp: 'float') -> None", "ReplayResult": "class alphalab.backtesting.state.ReplayResult(backtest: 'BacktestResult', replay_status: 'str', records_replayed: 'int', last_record: 'MarketRecord | None') -> None", "RunConfig": "class alphalab.runtime.run.RunConfig(pipeline: 'ExecutionPipelineConfig', mode: 'ExecutionMode', fill_policy: 'FillPolicy' = , seed: 'int | None' = None, start_timestamp: 'float' = 0.0, ordering: 'OrderingGuarantee' = , max_market_data_age_seconds: 'float | None' = None, years_elapsed: 'float | None' = None, risk_free_rate: 'float' = 0.0, compile_analytics: 'bool' = True, periods_per_year: 'float | None' = None, halt_on_strategy_failure: 'bool' = False) -> None", + "RunSchedule": "class alphalab.backtesting.schedule.RunSchedule(entries: 'tuple[ScheduleEntry, ...]' = (), rate_max_age_seconds: 'float | None' = None) -> None", "RunState": "class alphalab.runtime.run.RunState(config: 'RunConfig', pipeline: 'ExecutionPipelineState', processed: 'int' = 0, current_timestamp: 'float' = 0.0, last_record_timestamp: 'float | None' = None, source_id: 'str | None' = None, steps: 'AppendOnlyLog[RunStep]' = , skipped: 'AppendOnlyLog[SkippedRecord]' = , last_slice_at: 'float | None' = None, observations_delivered: 'int' = 0, last_observation: 'tuple[float, str] | None' = None) -> None", "RunStep": "class alphalab.runtime.run.RunStep(index: 'int', event_id: 'str', timestamp: 'float', orders: 'tuple[OMSOrder, ...]', reports: 'tuple[ExecutionReport, ...]', fills: 'tuple[CoreFill, ...]', equity: 'Decimal') -> None", + "ScheduleCursor": "class alphalab.backtesting.schedule.ScheduleCursor(schedule: 'RunSchedule', position: 'int', rates: 'FxRates') -> None", + "ScheduleValidationError": "class alphalab.backtesting.schedule.ScheduleValidationError", + "ScheduledAssignment": "class alphalab.backtesting.schedule.ScheduledAssignment(assignment: 'Assignment', at: 'float') -> None", + "ScheduledCashFlow": "class alphalab.backtesting.schedule.ScheduledCashFlow(flow: 'CashFlow', at: 'float') -> None", + "ScheduledDistribution": "class alphalab.backtesting.schedule.ScheduledDistribution(asset_id: 'str', per_unit: 'Decimal', currency: 'str', at: 'float', kind: 'CashFlowKind' = , reference: 'str' = '') -> None", + "ScheduledExercise": "class alphalab.backtesting.schedule.ScheduledExercise(exercise: 'Exercise', at: 'float') -> None", + "ScheduledExpiry": "class alphalab.backtesting.schedule.ScheduledExpiry(expiry: 'OptionExpiry', at: 'float') -> None", + "ScheduledFundingRate": "class alphalab.backtesting.schedule.ScheduledFundingRate(asset_id: 'str', rate: 'Decimal', at: 'float', reference: 'str' = '') -> None", + "ScheduledSettlement": "class alphalab.backtesting.schedule.ScheduledSettlement(settlement: 'ContractSettlement', at: 'float') -> None", + "ScheduledSplit": "class alphalab.backtesting.schedule.ScheduledSplit(split: 'Split', at: 'float') -> None", "StaticFill": "class alphalab.execution.policy.StaticFill(status: alphalab.execution.fill.FillStatus = , quantity: decimal.Decimal | None = None) -> None", "UnpricedAsset": "class alphalab.runtime.execution_pipeline.UnpricedAsset(asset_id: 'str', reason: 'UnpricedReason', detail: 'str', first_timestamp: 'float', last_timestamp: 'float', occurrences: 'int') -> None", "UnpricedReason": "enum alphalab.runtime.execution_pipeline.UnpricedReason[NO_PRICE_OBSERVED, NOT_REGISTERED, REGISTERED_BUT_UNPRICED]", "UnsupportedRecordError": "class alphalab.market.exceptions.UnsupportedRecordError", + "VenueCheck": "class alphalab.backtesting.algorithmic.VenueCheck(declaration: 'CapabilityDeclaration', account_id: 'str', features: 'frozenset[Capability]' = frozenset()) -> None", "advance": "function alphalab.backtesting.engine.advance(state: 'RunState', record: 'MarketRecord', context_factory: 'ContextFactory') -> 'tuple[RunState, ExecutionPipelineResult | None]'", "commission_paid": "function alphalab.backtesting.views.commission_paid(result: alphalab.backtesting.state.BacktestResult) -> decimal.Decimal", "equity_values": "function alphalab.backtesting.views.equity_values(result: alphalab.backtesting.state.BacktestResult) -> tuple[decimal.Decimal, ...]", @@ -396,11 +424,11 @@ "REPLAY_SCHEMA_HISTORY": "value alphalab.persistence.upgrade.SchemaHistory", "REPLAY_SNAPSHOT_SCHEMA": "value builtins.int", "ReplayEventRecord": "class alphalab.backtesting.replay_snapshot.ReplayEventRecord(event_type: 'str', event: 'ReplaySystemEvent') -> None", - "ReplaySnapshot": "class alphalab.backtesting.replay_snapshot.ReplaySnapshot(run: 'RunSnapshot', session: 'ReplaySession', status: 'ReplayStatus', current_index: 'int', current_timestamp: 'float', real_start_time: 'float', real_current_time: 'float', events: 'tuple[ReplayEventRecord, ...]', cursor_position: 'IdStreamPosition', records_total: 'int', dataset_digest: 'str', last_event_id: 'str | None', next_event_id: 'str', events_dropped: 'int' = 0, schema_version: 'int' = 1) -> None", + "ReplaySnapshot": "class alphalab.backtesting.replay_snapshot.ReplaySnapshot(run: 'RunSnapshot', session: 'ReplaySession', status: 'ReplayStatus', current_index: 'int', current_timestamp: 'float', real_start_time: 'float', real_current_time: 'float', events: 'tuple[ReplayEventRecord, ...]', cursor_position: 'IdStreamPosition', records_total: 'int', dataset_digest: 'str', last_event_id: 'str | None', next_event_id: 'str', events_dropped: 'int' = 0, schedule_digest: 'str | None' = None, algorithms: 'Mapping[str, Any] | None' = None, schema_version: 'int' = 2) -> None", "capture": "function alphalab.backtesting.replay_snapshot.capture(paused: 'PausedReplay') -> 'ReplaySnapshot'", "dataset_digest": "function alphalab.backtesting.replay_snapshot.dataset_digest(dataset: 'MarketDataset') -> 'str'", "from_primitives": "function alphalab.backtesting.replay_snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'ReplaySnapshot'", - "restore": "function alphalab.backtesting.replay_snapshot.restore(snapshot: 'ReplaySnapshot', objects: 'RunObjects', dataset: 'MarketDataset') -> 'PausedReplay'" + "restore": "function alphalab.backtesting.replay_snapshot.restore(snapshot: 'ReplaySnapshot', objects: 'RunObjects', dataset: 'MarketDataset', schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), execution: 'ExecutionPlan | None' = None) -> 'PausedReplay'" }, "alphalab.broker": { "ANY_LISTING_VENUE": "value builtins.str", @@ -409,7 +437,7 @@ "BROKER_STATUS_EQUIVALENTS": "value builtins.mappingproxy", "BrokerAccount": "class alphalab.broker.account.BrokerAccount(account_id: str, cash: decimal.Decimal, equity: decimal.Decimal, buying_power: decimal.Decimal, margin: decimal.Decimal, available_funds: decimal.Decimal, currency: str, broker_id: str = '', metadata: collections.abc.Mapping[str, str] = ) -> None", "BrokerAdapter": "class alphalab.broker.adapter.BrokerAdapter()", - "BrokerAssetClass": "enum alphalab.core.enums.AssetType[EQUITY, FUTURE, OPTION, FOREX, CRYPTO, CASH]", + "BrokerAssetClass": "enum alphalab.core.enums.AssetType[EQUITY, FUTURE, OPTION, FOREX, CRYPTO, CASH, BOND, INDEX]", "BrokerConnected": "class alphalab.broker.events.BrokerConnected(event_id: str, timestamp: float, broker_name: str) -> None", "BrokerDisconnected": "class alphalab.broker.events.BrokerDisconnected(event_id: str, timestamp: float, broker_name: str, reason: str) -> None", "BrokerEngine": "class alphalab.broker.broker.BrokerEngine()", @@ -668,7 +696,7 @@ "AccountId": "newtype alphalab.core.ids.AccountId", "AlphaLabCoreError": "class alphalab.core.exceptions.AlphaLabCoreError", "AssetId": "newtype alphalab.core.ids.AssetId", - "AssetType": "enum alphalab.core.enums.AssetType[EQUITY, FUTURE, OPTION, FOREX, CRYPTO, CASH]", + "AssetType": "enum alphalab.core.enums.AssetType[EQUITY, FUTURE, OPTION, FOREX, CRYPTO, CASH, BOND, INDEX]", "CANCEL_REQUESTABLE_STATUSES": "value builtins.frozenset", "CAPABILITY_DECLARATION_SCHEME": "value builtins.str", "CAPABILITY_LEVELS": "value builtins.mappingproxy", @@ -1372,19 +1400,24 @@ }, "alphalab.instrument": { "ALPHALAB_INSTRUMENT_NAMESPACE": "value uuid.UUID", + "AutomaticExercise": "class alphalab.instrument.terms.AutomaticExercise(threshold: 'Decimal') -> None", "CASH_EQUITY": "value alphalab.conventions.economics.InstrumentEconomics", "ClassificationHistory": "class alphalab.instrument.classification.ClassificationHistory(entries: 'tuple[SectorClassification, ...]' = ) -> None", + "ContractTerms": "alias ContractTerms", "DatedAlias": "class alphalab.instrument.record.DatedAlias(provider: 'str', symbol: 'str', valid_from: 'float | None', valid_to: 'float | None') -> None", + "Deliverable": "class alphalab.instrument.terms.Deliverable(asset_id: 'str', units: 'Decimal') -> None", "DimensionClassification": "class alphalab.instrument.classification.DimensionClassification(label: 'str | None', source: 'str' = 'operator', as_of: 'float | None' = None) -> None", "DimensionHistory": "class alphalab.instrument.classification.DimensionHistory(entries: 'tuple[DimensionClassification, ...]' = ) -> None", + "FutureTerms": "class alphalab.instrument.terms.FutureTerms(settlement: 'SettlementStyle', deliverable: 'Deliverable | None') -> None", "INSTRUMENT_KEY_SCHEME": "value builtins.str", "InstrumentEconomics": "class alphalab.conventions.economics.InstrumentEconomics(multiplier: 'Decimal', settlement: 'SettlementModel', lot: 'LotSpecification | None' = None, minimum_notional: 'Decimal | None' = None, allows_negative_prices: 'bool' = False) -> None", "InstrumentError": "class alphalab.instrument.exceptions.InstrumentError", "InstrumentInputError": "class alphalab.instrument.exceptions.InstrumentInputError", - "InstrumentRecord": "class alphalab.instrument.record.InstrumentRecord(symbol: 'str', asset_type: 'AssetType', exchange: 'str', currency: 'str', sector: 'str | None' = None, aliases: 'Mapping[str, str]' = , dated_aliases: 'tuple[DatedAlias, ...]' = (), economics: 'InstrumentEconomics | None' = None) -> None", + "InstrumentRecord": "class alphalab.instrument.record.InstrumentRecord(symbol: 'str', asset_type: 'AssetType', exchange: 'str', currency: 'str', sector: 'str | None' = None, aliases: 'Mapping[str, str]' = , dated_aliases: 'tuple[DatedAlias, ...]' = (), economics: 'InstrumentEconomics | None' = None, last_trade_at: 'float | None' = None, terms: 'ContractTerms | None' = None) -> None", "InstrumentRegistrationError": "class alphalab.instrument.exceptions.InstrumentRegistrationError", - "InstrumentRegistry": "class alphalab.instrument.registry.InstrumentRegistry(instruments: 'PersistentMap[str, InstrumentRecord]' = , by_provider: 'PersistentMap[str, PersistentMap[str, str]]' = , classifications: 'PersistentMap[str, ClassificationHistory]' = , dated: 'PersistentMap[str, PersistentMap[str, tuple[tuple[DatedAlias, str], ...]]]' = , dimensions: 'PersistentMap[str, PersistentMap[str, DimensionHistory]]' = , members: 'PersistentMap[str, PersistentMap[str, PersistentMap[str, None]]] | None' = None) -> None", + "InstrumentRegistry": "class alphalab.instrument.registry.InstrumentRegistry(instruments: 'PersistentMap[str, InstrumentRecord]' = , by_provider: 'PersistentMap[str, PersistentMap[str, str]]' = , classifications: 'PersistentMap[str, ClassificationHistory]' = , dated: 'PersistentMap[str, PersistentMap[str, tuple[tuple[DatedAlias, str], ...]]]' = , dimensions: 'PersistentMap[str, PersistentMap[str, DimensionHistory]]' = , members: 'PersistentMap[str, PersistentMap[str, PersistentMap[str, None]]] | None' = None, expiring: 'PersistentMap[str, float] | None' = None) -> None", "OPERATOR": "value builtins.str", + "OptionTerms": "class alphalab.instrument.terms.OptionTerms(option_type: 'OptionType', strike: 'Decimal', exercise_style: 'ExerciseStyle', settlement: 'SettlementStyle', deliverable: 'Deliverable', expires_at: 'float', automatic_exercise: 'AutomaticExercise | None', exercisable_from: 'float | None' = None) -> None", "SECTOR": "value builtins.str", "SectorClassification": "class alphalab.instrument.classification.SectorClassification(sector: 'str | None', source: 'str' = 'operator', as_of: 'float | None' = None) -> None", "SettlementModel": "enum alphalab.conventions.economics.SettlementModel[CASH_EQUITY, FUTURES_VARIATION, OPTION_PREMIUM, PERPETUAL]", @@ -1414,8 +1447,8 @@ "DatedAliasRecord": "class alphalab.instrument.snapshot.DatedAliasRecord(provider: 'str', symbol: 'str', valid_from: 'float | None', valid_to: 'float | None') -> None", "INSTRUMENT_SCHEMA_HISTORY": "value alphalab.persistence.upgrade.SchemaHistory", "INSTRUMENT_SNAPSHOT_SCHEMA": "value builtins.int", - "InstrumentRegistryRecord": "class alphalab.instrument.snapshot.InstrumentRegistryRecord(symbol: 'str', asset_type: 'AssetType', exchange: 'str', currency: 'str', sector: 'str | None', aliases: 'Mapping[str, str]', classifications: 'tuple[ClassificationRecord, ...]', dated_aliases: 'tuple[DatedAliasRecord, ...]' = (), later_aliases: 'tuple[tuple[str, str], ...]' = (), later_dated_aliases: 'tuple[DatedAliasRecord, ...]' = (), economics: 'InstrumentEconomics | None' = None, dimensions: 'Mapping[str, tuple[DimensionRecord, ...]]' = ) -> None", - "InstrumentRegistrySnapshot": "class alphalab.instrument.snapshot.InstrumentRegistrySnapshot(instruments: 'tuple[InstrumentRegistryRecord, ...]', schema_version: 'int' = 3) -> None", + "InstrumentRegistryRecord": "class alphalab.instrument.snapshot.InstrumentRegistryRecord(symbol: 'str', asset_type: 'AssetType', exchange: 'str', currency: 'str', sector: 'str | None', aliases: 'Mapping[str, str]', classifications: 'tuple[ClassificationRecord, ...]', dated_aliases: 'tuple[DatedAliasRecord, ...]' = (), later_aliases: 'tuple[tuple[str, str], ...]' = (), later_dated_aliases: 'tuple[DatedAliasRecord, ...]' = (), economics: 'InstrumentEconomics | None' = None, dimensions: 'Mapping[str, tuple[DimensionRecord, ...]]' = , last_trade_at: 'float | None' = None, terms: 'Mapping[str, Any] | None' = None) -> None", + "InstrumentRegistrySnapshot": "class alphalab.instrument.snapshot.InstrumentRegistrySnapshot(instruments: 'tuple[InstrumentRegistryRecord, ...]', schema_version: 'int' = 4) -> None", "capture": "function alphalab.instrument.snapshot.capture(registry: 'InstrumentRegistry') -> 'InstrumentRegistrySnapshot'", "from_primitives": "function alphalab.instrument.snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'InstrumentRegistrySnapshot'", "restore": "function alphalab.instrument.snapshot.restore(snapshot: 'InstrumentRegistrySnapshot') -> 'InstrumentRegistry'" @@ -1511,7 +1544,7 @@ "ROUTING_SETTING_PREFIX": "value builtins.str", "ReconciliationTolerances": "class alphalab.lifecycle.reconciliation.ReconciliationTolerances(order_quantity: 'Tolerance', order_price: 'Tolerance', fill_quantity: 'Tolerance', fill_price: 'Tolerance', commission: 'Tolerance', position_quantity: 'Tolerance', cash: 'Tolerance') -> None", "ReproducibilityAssessment": "class alphalab.lifecycle.reproducibility.ReproducibilityAssessment(manifest_id: 'str', identity_verified: 'bool', gaps: 'tuple[str, ...]', rerun: 'RerunOutcome', rerun_detail: 'tuple[str, ...]', external_requirements: 'tuple[ExternalRequirement, ...]', findings: 'tuple[str, ...]') -> None", - "ReproducibilityManifest": "class alphalab.lifecycle.reproducibility.ReproducibilityManifest(manifest_id: 'str', kind: 'ResultKind', result_id: 'str', dataset_version: 'str', dataset_content_hash: 'str', configuration: 'str', configuration_id: 'str', seed: 'int | None', seed_role: 'SeedRole', engine: 'EngineIdentity', fingerprint: 'StrategyFingerprint | None', build: 'EngineBuild | None' = None) -> None", + "ReproducibilityManifest": "class alphalab.lifecycle.reproducibility.ReproducibilityManifest(manifest_id: 'str', kind: 'ResultKind', result_id: 'str', dataset_version: 'str', dataset_content_hash: 'str', configuration: 'str', configuration_id: 'str', seed: 'int | None', seed_role: 'SeedRole', engine: 'EngineIdentity', fingerprint: 'StrategyFingerprint | None', build: 'EngineBuild | None' = None, schedule_digest: 'str | None' = None, dataset_parts: 'tuple[tuple[str, str], ...]' = (), execution_plan_id: 'str | None' = None) -> None", "RequirementCheck": "class alphalab.lifecycle.portability.RequirementCheck(requirement: 'PortabilityRequirement', outcome: 'RequirementOutcome', detail: 'str', blockers: 'tuple[str, ...]' = ()) -> None", "RequirementOutcome": "enum alphalab.lifecycle.portability.RequirementOutcome[SATISFIED, BLOCKED, NOT_VERIFIED, NOT_APPLICABLE]", "RerunOutcome": "enum alphalab.lifecycle.reproducibility.RerunOutcome[NOT_ATTEMPTED, REPRODUCED, DIVERGED, INPUTS_DIFFER]", @@ -1577,14 +1610,14 @@ "build_fingerprint": "function alphalab.lifecycle.fingerprint.build_fingerprint(name: 'str', strategy_id: 'str', code: 'CodeIdentity', dependencies: 'DependencyManifest', parameters: 'Mapping[str, ParamValue]', research: 'ResearchConfiguration', engine: 'EngineIdentity') -> 'StrategyFingerprint'", "build_specification": "function alphalab.lifecycle.specification.build_specification(strategy: 'StrategyVersionRef', parameters: 'Mapping[str, ParamValue]', datasets: 'Sequence[DatasetAssumption]', risk: 'RiskLimits', capital: 'CapitalPolicy', broker: 'BrokerRequirements', market: 'MarketRequirements', runtime: 'RuntimeRequirements') -> 'DeploymentSpecification'", "canonical_fingerprint_key": "function alphalab.lifecycle.fingerprint.canonical_fingerprint_key(name: 'str', strategy_id: 'str', code: 'CodeIdentity', dependencies: 'DependencyManifest', parameters: 'Mapping[str, ParamValue]', research: 'ResearchConfiguration', engine: 'EngineIdentity') -> 'str'", - "canonical_manifest_key": "function alphalab.lifecycle.reproducibility.canonical_manifest_key(kind: 'ResultKind', result_id: 'str', dataset_version: 'str', dataset_content_hash: 'str', configuration_id: 'str', seed: 'int | None', seed_role: 'SeedRole', engine: 'EngineIdentity', fingerprint: 'str | None', build: 'EngineBuild | None' = None) -> 'str'", + "canonical_manifest_key": "function alphalab.lifecycle.reproducibility.canonical_manifest_key(kind: 'ResultKind', result_id: 'str', dataset_version: 'str', dataset_content_hash: 'str', configuration_id: 'str', seed: 'int | None', seed_role: 'SeedRole', engine: 'EngineIdentity', fingerprint: 'str | None', build: 'EngineBuild | None' = None, schedule_digest: 'str | None' = None, dataset_parts: 'tuple[tuple[str, str], ...]' = (), execution_plan_id: 'str | None' = None) -> 'str'", "certify_strategy": "function alphalab.lifecycle.certification.certify_strategy(fingerprint: 'StrategyFingerprint', specification: 'DeploymentSpecification', evidence: 'CertificationEvidence', budgets: 'Sequence[ResourceBudget]' = (), progression: 'StrategyProgression | None' = None) -> 'CertificationReport'", "code_identity_for": "function alphalab.lifecycle.fingerprint.code_identity_for(registration: 'StrategyRegistration', package: 'str', version: 'str | None', sources: 'Mapping[str, bytes] | None') -> 'CodeIdentity'", "compare_expected_paper_live": "function alphalab.lifecycle.comparison.compare_expected_paper_live(expected: 'RunObservations', paper: 'RunObservations', live: 'RunObservations', tolerances: 'Mapping[ComparisonMetric, Tolerance]') -> 'ThreeWayComparison'", "compare_runs": "function alphalab.lifecycle.comparison.compare_runs(left: 'RunObservations', right: 'RunObservations', tolerances: 'Mapping[ComparisonMetric, Tolerance]') -> 'PairComparison'", "dataset_assumption_from": "function alphalab.lifecycle.specification.dataset_assumption_from(dataset: 'Dataset', role: 'str') -> 'DatasetAssumption'", "deploy_strategy_version": "function alphalab.lifecycle.deployment.deploy_strategy_version(state: 'LifecycleState', governance: 'Governance', name: 'str', version: 'int', environment: 'str', timestamp: 'float', deployed_by: 'str' = '', notes: 'str' = '') -> 'tuple[LifecycleState, DeploymentRef]'", - "derive_manifest_id": "function alphalab.lifecycle.reproducibility.derive_manifest_id(kind: 'ResultKind', result_id: 'str', dataset_version: 'str', dataset_content_hash: 'str', configuration_id: 'str', seed: 'int | None', seed_role: 'SeedRole', engine: 'EngineIdentity', fingerprint: 'str | None', build: 'EngineBuild | None' = None) -> 'str'", + "derive_manifest_id": "function alphalab.lifecycle.reproducibility.derive_manifest_id(kind: 'ResultKind', result_id: 'str', dataset_version: 'str', dataset_content_hash: 'str', configuration_id: 'str', seed: 'int | None', seed_role: 'SeedRole', engine: 'EngineIdentity', fingerprint: 'str | None', build: 'EngineBuild | None' = None, schedule_digest: 'str | None' = None, dataset_parts: 'tuple[tuple[str, str], ...]' = (), execution_plan_id: 'str | None' = None) -> 'str'", "derive_strategy_fingerprint": "function alphalab.lifecycle.fingerprint.derive_strategy_fingerprint(name: 'str', strategy_id: 'str', code: 'CodeIdentity', dependencies: 'DependencyManifest', parameters: 'Mapping[str, ParamValue]', research: 'ResearchConfiguration', engine: 'EngineIdentity') -> 'str'", "differing_parameters": "function alphalab.lifecycle.fingerprint.differing_parameters(left: 'Mapping[str, ParamValue]', right: 'Mapping[str, ParamValue]') -> 'tuple[str, ...]'", "digest_run": "function alphalab.lifecycle.reproducibility.digest_run(result: 'BacktestResult') -> 'RunDigest'", @@ -1608,7 +1641,7 @@ "latest_strategy_version": "function alphalab.lifecycle.strategy_version.latest_strategy_version(registry: 'StrategyVersionRegistry', name: 'str') -> 'StrategyVersion'", "list_strategy_versions": "function alphalab.lifecycle.strategy_version.list_strategy_versions(registry: 'StrategyVersionRegistry', name: 'str') -> 'tuple[StrategyVersion, ...]'", "live_environments": "function alphalab.lifecycle.views.live_environments(state: 'LifecycleState') -> 'tuple[str, ...]'", - "manifest_for_run": "function alphalab.lifecycle.reproducibility.manifest_for_run(result: 'BacktestResult', dataset: 'VersionedDataset', fingerprint: 'StrategyFingerprint', engine: 'EngineIdentity', *, build: 'EngineBuild | None') -> 'ReproducibilityManifest'", + "manifest_for_run": "function alphalab.lifecycle.reproducibility.manifest_for_run(result: 'BacktestResult', dataset: 'VersionedDataset', fingerprint: 'StrategyFingerprint', engine: 'EngineIdentity', *, build: 'EngineBuild | None', schedule: 'RunSchedule | None' = None, execution: 'ExecutionPlan | None' = None) -> 'ReproducibilityManifest'", "manifest_for_study": "function alphalab.lifecycle.reproducibility.manifest_for_study(result: 'StudyResult', dataset: 'VersionedDataset', engine: 'EngineIdentity', fingerprint: 'StrategyFingerprint | None' = None, *, build: 'EngineBuild | None') -> 'ReproducibilityManifest'", "manifest_gaps": "function alphalab.lifecycle.reproducibility.manifest_gaps(manifest: 'ReproducibilityManifest') -> 'tuple[str, ...]'", "normalize_distribution_name": "function alphalab.lifecycle.fingerprint.normalize_distribution_name(name: 'str') -> 'str'", @@ -2007,6 +2040,7 @@ }, "alphalab.portfolio": { "Account": "class alphalab.portfolio.account.Account(account_id: str, base_currency: str, name: str, created_at: float, status: str = 'ACTIVE', metadata: collections.abc.Mapping[str, Any] = , currency_units: alphalab.common.currency_units.CurrencyUnits = CurrencyUnits({})) -> None", + "Assignment": "class alphalab.portfolio.corporate_actions.Assignment(asset_id: 'str', contracts: 'Decimal', underlying_value: 'Decimal | None' = None, reference: 'str' = '') -> None", "BOOK_VALUATION_SCHEME": "value builtins.str", "BookValuation": "class alphalab.portfolio.multi_strategy.BookValuation(portfolio_id: 'str', book_id: 'str', reporting_currency: 'str', as_of: 'float', lines: 'tuple[HoldingValuation, ...]', strategies: 'tuple[StrategyValuation, ...]', instruments: 'tuple[InstrumentValuation, ...]', exposure_by_currency: 'CurrencyAmounts', reporting_exposure_by_currency: 'Mapping[str, Decimal]', long_exposure: 'Decimal', short_exposure: 'Decimal', net_exposure: 'Decimal', gross_exposure: 'Decimal', cash: 'Decimal', unassigned_cash: 'Decimal', nav: 'Decimal', conversions: 'tuple[FxConversion, ...]') -> None", "CashDeposited": "class alphalab.portfolio.events.CashDeposited(timestamp: float, account_id: str, amount: decimal.Decimal, currency: str) -> None", @@ -2017,8 +2051,10 @@ "CashWithdrawn": "class alphalab.portfolio.events.CashWithdrawn(timestamp: float, account_id: str, amount: decimal.Decimal, currency: str) -> None", "ContractExposure": "class alphalab.portfolio.contracts.ContractExposure(by_asset: 'Mapping[str, ContractNotional]', net: 'CurrencyAmounts', gross: 'CurrencyAmounts', underlying_units: 'Mapping[str, Decimal]') -> None", "ContractHolding": "class alphalab.portfolio.contracts.ContractHolding(position: 'Position', convention: 'MarketConvention') -> None", + "ContractSettlement": "class alphalab.portfolio.corporate_actions.ContractSettlement(asset_id: 'str', price: 'Decimal', reference: 'str' = '') -> None", "CurrencyAttribution": "class alphalab.portfolio.fx_research.CurrencyAttribution(currency: 'str', opening_local: 'Decimal', closing_local: 'Decimal', opening_reporting: 'Decimal', closing_reporting: 'Decimal', opening_rate: 'FxRate', closing_rate: 'FxRate', local_return: 'Decimal', currency_return: 'Decimal', total: 'Decimal') -> None", "CurrencyAttributionReport": "class alphalab.portfolio.fx_research.CurrencyAttributionReport(reporting_currency: 'str', opening_timestamp: 'float', closing_timestamp: 'float', by_currency: 'tuple[CurrencyAttribution, ...]', local_return: 'Decimal', currency_return: 'Decimal', total: 'Decimal', rounding: 'Decimal') -> None", + "Exercise": "class alphalab.portfolio.corporate_actions.Exercise(asset_id: 'str', contracts: 'Decimal', underlying_value: 'Decimal | None' = None, reference: 'str' = '') -> None", "ExposureEngine": "class alphalab.portfolio.exposure.ExposureEngine()", "ForwardTerms": "class alphalab.portfolio.fx_research.ForwardTerms(value_date: 'date', spot_date: 'date', base_rate: 'float', quote_rate: 'float', basis: 'DayCount') -> None", "FutureDatedRateError": "class alphalab.portfolio.fx.FutureDatedRateError", @@ -2038,6 +2074,7 @@ "MultiStrategyBook": "class alphalab.portfolio.multi_strategy.MultiStrategyBook(portfolio_id: 'str', sleeves: 'tuple[StrategySleeve, ...]', unassigned_cash: 'CurrencyAmounts') -> None", "NAVCalculator": "class alphalab.portfolio.nav.NAVCalculator()", "NO_RATES": "value alphalab.portfolio.fx.FxRates", + "OptionExpiry": "class alphalab.portfolio.corporate_actions.OptionExpiry(asset_id: 'str', underlying_value: 'Decimal', reference: 'str' = '') -> None", "PnLEngine": "class alphalab.portfolio.pnl.PnLEngine()", "PortfolioEngine": "class alphalab.portfolio.engine.PortfolioEngine()", "PortfolioEvent": "class alphalab.portfolio.events.PortfolioEvent(timestamp: float, account_id: str) -> None", @@ -2499,7 +2536,7 @@ "ExecutionPipelineConfig": "class alphalab.runtime.execution_pipeline.ExecutionPipelineConfig(account: 'Account', starting_cash: 'Decimal', budget: 'CapitalBudget', allocation_constraints: 'AllocationConstraints', risk_limits: 'RiskLimits', sizing_model: 'SizingModel' = , simulator: 'ExecutionSimulator' = , venue: 'str' = 'SIM', currency: 'str' = '', also_settles: 'frozenset[str]' = frozenset(), routing: 'ExecutionRouting' = , instruments: 'InstrumentRegistry | None' = None, fill_timing: 'FillTiming' = , calendars: 'VenueCalendars' = , retention: 'RetentionPolicy' = ) -> None", "ExecutionPipelineResult": "class alphalab.runtime.execution_pipeline.ExecutionPipelineResult(state: 'ExecutionPipelineState', market_event: 'MarketEvent', intents: 'tuple[Intent, ...]', order_requests: 'tuple[OrderRequest, ...]', risk_decisions: 'tuple[RiskDecision, ...]', oms_orders: 'tuple[OMSOrder, ...]', execution_reports: 'tuple[ExecutionReport, ...]', fills: 'tuple[CoreFill, ...]', trades: 'tuple[CoreTrade, ...]', unpriced_requests: 'tuple[OrderRequest, ...]' = , valuation: 'PortfolioValuationSnapshot | None' = None, settlement_refusals: 'tuple[SettlementRefusal, ...]' = ) -> None", "ExecutionPipelineState": "class alphalab.runtime.execution_pipeline.ExecutionPipelineState(config: 'ExecutionPipelineConfig', market: 'MarketState', strategy: 'StrategyRuntimeState', allocation: 'AllocationState', risk: 'RiskState', oms: 'OMSState', execution: 'ExecutionState', portfolio: 'PortfolioState', analytics: 'AnalyticsState', market_prices: 'Mapping[str, Decimal]' = , fills: 'AppendOnlyLog[CoreFill]' = , trades: 'AppendOnlyLog[CoreTrade]' = , trade_records: 'AppendOnlyLog[TradeRecord]' = , portfolio_snapshots: 'AppendOnlyLog[PortfolioSnapshot]' = , unpriced_assets: 'PersistentMap[str, UnpricedAsset]' = , id_position: 'IdStreamPosition' = ) -> None", - "ExecutionRouting": "enum alphalab.runtime.execution_pipeline.ExecutionRouting[SIMULATED, EXTERNAL]", + "ExecutionRouting": "enum alphalab.runtime.execution_pipeline.ExecutionRouting[SIMULATED, EXTERNAL, ALGORITHMIC]", "HistoryNotRetainedError": "class alphalab.runtime.exceptions.HistoryNotRetainedError", "LiveRunState": "class alphalab.runtime.live.LiveRunState(run: 'RunState', broker: 'BrokerState', routing: 'RoutingConfig', mapping: 'ExternalOrderMap' = , reconciliation: 'ReconciliationLog' = , routed: 'AppendOnlyLog[RoutedOrder]' = , settled: 'AppendOnlyLog[SettledExecution]' = , children: 'ChildOrderBindings' = , requests: 'RequestLedger' = , held: 'frozenset[str]' = frozenset()) -> None", "LiveSession": "class alphalab.runtime.live.LiveSession()", @@ -2534,6 +2571,8 @@ "restore_checkpoints": "function alphalab.runtime.checkpoint.restore_checkpoints(payloads: 'Sequence[str]', objects: 'RunObjects') -> 'RunState'", "route_child_order": "function alphalab.runtime.broker_routing.route_child_order(broker_state: 'BrokerState', broker: 'BrokerProtocol', parent: 'OMSOrder', child: 'ChildOrder', timestamp: 'float', *, mapping: 'ExternalOrderMap', children: 'ChildOrderBindings', config: 'RoutingConfig', capability: 'CompatibilityReport | None') -> 'ChildRoutingResult'", "route_order": "function alphalab.runtime.broker_routing.route_order(broker_state: 'BrokerState', broker: 'BrokerProtocol', oms_order: 'OMSOrder', timestamp: 'float', mapping: 'ExternalOrderMap | None' = None, *, config: 'RoutingConfig', capability: 'CompatibilityReport | None' = None) -> 'RoutingResult'", + "session_close": "function alphalab.runtime.calendars.session_close(calendar: 'MarketCalendar', day: 'date') -> 'float'", + "settles_at": "function alphalab.runtime.calendars.settles_at(calendar: 'MarketCalendar', rule: 'SettlementRule', trade_day: 'date') -> 'float'", "venue_order_type": "function alphalab.runtime.broker_routing.venue_order_type(oms_order: 'OMSOrder', routing: 'RoutingConfig') -> 'OrderType'", "wants_slices": "function alphalab.runtime.execution_pipeline.wants_slices(strategies: 'StrategyRuntimeState') -> 'bool'" }, @@ -2975,7 +3014,7 @@ "restore": { "bound_to": [ "function alphalab.allocation.snapshot.restore(snapshot: 'AllocationSnapshot') -> 'AllocationState'", - "function alphalab.backtesting.replay_snapshot.restore(snapshot: 'ReplaySnapshot', objects: 'RunObjects', dataset: 'MarketDataset') -> 'PausedReplay'", + "function alphalab.backtesting.replay_snapshot.restore(snapshot: 'ReplaySnapshot', objects: 'RunObjects', dataset: 'MarketDataset', schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), execution: 'ExecutionPlan | None' = None) -> 'PausedReplay'", "function alphalab.broker.snapshot.restore(snapshot: 'BrokerSnapshot') -> 'tuple[BrokerState, ExternalOrderMap, ReconciliationLog]'", "function alphalab.instrument.snapshot.restore(snapshot: 'InstrumentRegistrySnapshot') -> 'InstrumentRegistry'", "function alphalab.lifecycle.snapshot.restore(snapshot: 'LifecycleSnapshot', models: 'Mapping[str, object]') -> 'LifecycleState'", diff --git a/docs/audit/FUTURE_READINESS_V4_1_1.md b/docs/audit/FUTURE_READINESS_V4_1_1.md new file mode 100644 index 00000000..2bc5ac01 --- /dev/null +++ b/docs/audit/FUTURE_READINESS_V4_1_1.md @@ -0,0 +1,426 @@ +# AlphaLab v4.1.1 Future-Readiness Report + +**Candidate:** branch `work/v4.1.1-future-readiness`, created from `main` at +`33e61d3edbd041bcd125d5dea913c00cdc9da613` -- the commit the `v4.1.0` tag names. +Nothing was committed, pushed, tagged or published; the tag and its artifacts +were not touched; `alphalab/common/_version.py` still says `4.1.0`. +**Companions:** +[`STRATEGY_CAPABILITY_MATRIX.md`](STRATEGY_CAPABILITY_MATRIX.md), +[`STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md`](STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md), +[ADR-0051](../ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md), +[`../COMPOSING_STRATEGIES.md`](../COMPOSING_STRATEGIES.md), and the ledger entries +KD-005, DOC-012, TST-023, OFE-027, OFE-028 and FUT-003 to FUT-013. + +The work ran in four milestones on one branch. **The audit** read the engine, +fixed two defects, connected information to every driver and several datasets +to one run, built the acceptance scenario (example 72) and named twelve gaps +(FUT-003 to FUT-012). **The continuation** implemented eight of them (FUT-003 to +FUT-008, FUT-010, FUT-012) and named one more (FUT-013). **The FUT-009 +milestone** put execution algorithms on the historical drivers (FUT-009). +**The FUT-013 milestone** made a derivative's lifecycle -- exercise, assignment, +expiry, delivery -- the contract's declared terms, carried out by the run in any +market a calendar describes, and named the gaps it found (FUT-014 to FUT-017). +Accepted as future work: FUT-011 and FUT-014 to FUT-017, with FUT-002 from v4.1. Every figure below was read from a command's own +output in the session that produced it. + +--- + +## 1. Verdict + +**The strategy contract is open, and the engine composes heterogeneous research +inputs, in several currencies and asset classes, without leaking time, on every +driver.** A researcher can write a strategy no template anticipated; trade an +equity and a future from two datasets on prices, social sentiment and macro +releases with revisions; put rates, funding, dividends, coupons, perpetual +funding, splits, timers and contract settlements on one schedule; hold a +cash-settled future or option to expiry and a fixed-rate bond to maturity; +train a model on data before a boundary and trade it after; have an execution +algorithm work each order in children over the historical bars, every child +filled by the run's own liquidity cap and cost model and settled on one parent +order; declare an option's or a future's terms -- American or European, cash or +physical, its deliverable, its exercise rule -- and have the run exercise, +assign and deliver it through the portfolio's own accounting, on instants a +declared market calendar gives; and reproduce, replay, pause durably, store and +continue every such run to the identical result. The acceptance scenario proves the time semantics against a fixture +built to leak, including a control that does leak and is caught. + +It is **not** a claim that every strategy family is production-ready, nor that +any market's rules are supported: AlphaLab ships none, and supports a market when +its calendar and contracts are declared (matrix 2.27). Engine gaps remain, each +on the ROADMAP with acceptance criteria: a queue model (FUT-011), a +strategy-facing exercise instruction (FUT-014), settlement-date accounting +(FUT-015), contract terms beyond one deliverable and two exercise styles +(FUT-016), and margin and forced liquidation (FUT-017); a backtest also +simulates one venue, so routing between venues is evaluated only on the live +and paper path. Real venues and +data are, as before, the host's. "Future-ready" is therefore claimed only for +what sections 6 and 7 evidence. + +**The release number is the maintainer's decision.** The candidate adds public +API, three persisted enum members and two persisted schema steps with upgrades, so it is a minor release +(v4.2.0) by semantic versioning and by `docs/api/PUBLIC_API.md`'s policy +(section 9). Until the version is set, three guards that read the package +version fail by construction (section 8.2); everything else passes. + +--- + +## 2. Starting state + +**At the audit (phase zero).** + +| | | +| --- | --- | +| Repository | `/Users/varunkumarsingh/developer/AlphaLab` (root = working directory), branch `main` | +| HEAD | `33e61d3edbd041bcd125d5dea913c00cdc9da613` = `v4.1.0` (annotated tag; `git rev-parse v4.1.0^{commit}`) = baseline named in the brief | +| Working tree | `git status --short --untracked-files=all`: empty; no stash; no staged or unstaged work | +| Stale namespace directories | none (no `alphalab/` directory without Python files) | +| Version metadata | `alphalab/common/_version.py` = `4.1.0` (the single source, read by hatch and at import) | +| Dev venv | CPython 3.12.4; installed metadata reports `4.0.0` -- a stale editable install, so `tests/unit/test_package_metadata.py` fails there; a fresh venv reports `4.1.0` and passes | +| CI | `ci.yml`: CPython 3.12/3.13/3.14 x Ubuntu/macOS -- ruff, format, `mypy .`, `pytest -W error`, every example under `-W error`, certification `--check`, build, `twine check --strict`, release-path verification; plus `benchmarks.yml`, `preflight.yml`, `release.yml`, `codeql.yml` | +| Contracts | `docs/api/public_api.json` (2,628 exports at v4.1.0), `docs/INTEGRATION.md`, `docs/api/PUBLIC_API.md` (stability policy), `docs/audit/release_certification.json` | +| `dist/` | holds v3.9.0 and v4.0.0 artifacts from earlier sessions; left as it was | + +**Baseline gates** (before any change): `pytest -W error` **9,671 passed, 1 +failed** in the dev venv (the stale metadata above) and **9,672 passed** in a +fresh CPython 3.12 venv built from a clean mirror of the tree; ruff, format and +`mypy .` clean; `certify_release.py --check`: 11 checks passed, matching. + +**At the continuation.** The same branch, the audit's work uncommitted and +preserved (25 files modified, 10 added); nothing reset, discarded or recreated. + +--- + +## 3. Method + +Archaeology before code. For each of the 43 packages and `alphalab.api`: the +package docstring and exports; the AST import graph (scratch script below, run +on the checkout); the drivers and the run step read in full where a capability +crosses them; and the integration tests and examples that exercise it. Claims +in documentation were checked against code; two were false (section 6). The +continuation re-read the ledger, ADR-0051 and every FUT entry against source +before implementing, and kept the ledger, the matrix, the ADR, the ROADMAP and +the tests synchronized as each item closed. + +```bash +python - <<'PY' +import ast, collections, pathlib +pkg = pathlib.Path("alphalab") +names = {p.stem for p in pkg.iterdir() if (p / "__init__.py").exists() or p.suffix == ".py"} +edges = collections.defaultdict(set) +for path in pkg.rglob("*.py"): + owner = path.relative_to(pkg).parts[0].removesuffix(".py") + for node in ast.walk(ast.parse(path.read_text())): + mods = [a.name for a in node.names] if isinstance(node, ast.Import) else ( + [node.module] if isinstance(node, ast.ImportFrom) and node.module else []) + for mod in mods: + part = mod.split(".") + if part[0] == "alphalab" and len(part) > 1 and part[1] in names and part[1] != owner: + edges[owner].add(part[1]) +for name in sorted(names): + users = sorted(o for o, e in edges.items() if name in e) + print(f"{name:24} imported by {len(users):2}: {', '.join(users)}") +PY +``` + +--- + +## 4. The historical v3.1-v3.9 work, and where it is now + +Each release's capability is still present, public and tested; where its +package layout changed, the ledger says why. The cross-release integration +tests for each (`tests/integration/test_v32_capabilities.py` to +`test_v39_capabilities.py`) pass on the final tree. + +| Release | Delivered | Where it lives now | On which path | Finding | +| --- | --- | --- | --- | --- | +| v3.1 | Universal data engine: provenance, derived dataset versions, the application API | `alphalab.data`, `alphalab.api` | ingestion -> backtest, replay | intact; several datasets per run added (OFE-028) | +| v3.2 | Study path: features, forward returns, signal diagnostics, walk-forward, purged CV, robustness, overfitting | `alphalab.factor_library`, `alphalab.research`, `api.run_study` | research; a study's features evaluated in a strategy (FUT-006) | intact | +| v3.3 | Institutional backtesting: costs, capacity, attribution, risk decomposition, scenarios | `alphalab.execution`, `analytics`, `scenario` | backtest (costs), research | intact; `scenario` standalone by design | +| v3.4 | Global markets: conventions, calendars, futures chains and rolls, options IV and surfaces, FX research, perpetuals, bonds | `alphalab.conventions`, `futures`, `options`, `crypto`, `portfolio.fx_research`, `macro.bond` | booking on the path since v3.11 (ACC-005); expiry, settlement, funding and bonds on the path now (FUT-004, FUT-010, FUT-012) | intact; physical delivery FUT-013 | +| v3.5 | Research-to-live progression, deployment specifications, runtime health, expected/paper/live comparison, broker reconciliation | `alphalab.lifecycle` | lifecycle | intact | +| v3.6 | Fingerprints, reproducibility manifests, certification, portability | `alphalab.lifecycle` | every path | intact; a manifest now names a run's schedule and a combined dataset's parts (FUT-005) | +| v3.7 | Point-in-time events, alternative data, fundamentals, regimes, adaptive strategies | `alphalab.alt_data`, `research`, `factor_library`, `strategy.adaptive` | research; delivery on every driver (OFE-027) | intact | +| v3.8 | Construction, Black-Litterman, risk budgets, multi-strategy books, capital allocation | `alphalab.portfolio_optimizer`, `analytics`, `portfolio.multi_strategy`, `allocation.capital` | research; strategies call it | intact; `portfolio_optimizer` standalone by design | +| v3.9 | Universal execution contract: capabilities, venue lifecycle, algorithms, routing, execution quality | `alphalab.core`, `broker`, `execution` | live and paper | intact; algorithms not on the historical drivers (FUT-009) | + +Seven packages present at v3.9.0 are gone at v4.1.0, each by a recorded +decision: `enterprise` (SaaS identity, the host's -- BND-002), `workbench` (UI +state -- BND-003), `studio` (SCF-001; its one canonical type moved to +`alphalab.strategy.definition`), `feed`, `live` and `optimizer` (provider +scaffolds and duplicates of the canonical models -- SCF-002, SCF-003), and +`plugins` (a placeholder -- SCF-003). v3.10-v3.13 (the pre-v4 audit), v4.0 (the +freeze) and v4.1 (replay pause and resume) are recorded in their own audits; the +v4.0 historical inventory classified every v2.0-v3.13 deferral (257 items). + +--- + +## 5. Connected, already connected, isolated + +**Already connected** (verified, unchanged): the strategy contract, allocation, +risk, OMS, simulation and costs, accounting with instrument economics, calendars, +point-in-time information on the backtest, the lifecycle governance path, live +routing through the broker boundary, reproducibility. + +**Connected by this candidate:** + +* information on the replay, the facade and the live driver (OFE-027); +* several datasets in one run, with one derived, checkable identity (OFE-028); +* `context.market.bar` (KD-005), without which a strategy reading bars through + its context saw none; +* one `RunSchedule` for every timed input on both historical drivers and the + facade (FUT-004), durable across a replay pause (FUT-003); +* contract expiry and cash settlement (FUT-012) and bonds (FUT-010) on the path; +* the schedule and a combined dataset's parts in the manifest (FUT-005); +* `alphalab.macro` series as observations (FUT-008); `scheduler` timers onto + the historical drivers; a study's features and a fitted model on the + execution path, evidenced (FUT-006, FUT-007); +* `alphalab.execution.algorithms` onto both historical drivers (FUT-009): an + `ExecutionPlan` works every parent in children, each filled by the run's own + fill policy and cost model and settled on the parent; +* `alphalab.options`' exercise and settlement economics onto the execution path + (FUT-013): a contract's declared terms, carried out by the run's schedule + through the portfolio, the cash payoff equal to `resolve_expiration`'s. + +**Still isolated, by design or with a gap named:** `portfolio_optimizer` and +`scenario` (libraries a strategy or host calls; by design), `deep_learning` (a +network is held by a strategy as example 73 holds its model; no test trades +one), `feature_store` (consumed only by `ml`), +`distributed`/`cloud_research`/`cluster_scheduler` (process-pool research +infrastructure), `reporting`, `research_assistant`, `brokers`, +`reinforcement_learning` (its own loop over the real step). The matrix's section +1 lists every edge. + +--- + +## 6. What was corrected and delivered + +**Defects.** + +| ID | Finding | Fix | Test | +| --- | --- | --- | --- | +| KD-005 (high) | `context.market.bar(asset_id)` returned `None` for every asset since v2.10: the view looked up a bare asset id in an index keyed `"_"` | `MarketView.bar` finds the asset's bars by key, latest first, longer interval on a tie | `tests/regression/test_strategy_context_reads_bars.py` (5); four fail against v4.1.0's lookup in a scratch copy | +| DOC-012 (low) | The RL package and module docstrings reported the `apply_fill` double count (fixed v2.0.0, D1) and fill-only marking (fixed v2.1) as live | Docstrings say what is true and keep the history | the package's own `test_environment_round_trip_realizes_correct_pnl`; a direct check that `process_quote` marks the position | + +Found while implementing, and fixed with the item: a zero final price was +refused by `PortfolioEngine.apply_fill`, so an option could not expire worthless +(`final_settlement=True`, FUT-012); a run ending with only skipped records after +expiry escaped the held-past-expiry check (it now reads the last record handed, +skipped or not). Two guards failed on the finished tree and were examined, not +loosened: the v3.9 compatibility invariant reached its COMPATIBLE status only by +the luck of its seed (two draws in 600 at v4.1.0, none under three other seeds), +and `AssetType.BOND` moved the stream -- it now checks, on every draw, a +declaration built to offer what the flow needs, and passes on v4.1.0 too +(TST-023); the financial-defaults guard flagged `RunSchedule.of(funding_rates=())`, +an empty sequence of stated rates that charges nothing, now permitted by name +with that reason, as `rates` already was. + +**Delivered (additive unless stated).** + +| ID | What | Evidence | +| --- | --- | --- | +| OFE-027 | observations on `ReplayBacktest.run`, the facade and `LiveSession.deliver_observation` | `tests/regression/test_heterogeneous_inputs_on_every_driver.py` (82) | +| OFE-028 | `CombinedDataset`, `combine_datasets`, `combined_market_dataset` | `tests/unit/test_api_combined_datasets.py` (8) | +| FUT-004 | `alphalab.backtesting.schedule`: `RunSchedule` and `ScheduleCursor` carrying observations, FX rates, `Funding`, `ScheduledCashFlow`, per-unit `ScheduledDistribution`, `ScheduledFundingRate`, `ScheduledSplit`, timers and `ScheduledSettlement`; inputs before the records of their instant or after; duplicates refused; the rate table rebuilt from the schedule; `RunEngine.fund` | `tests/integration/test_scheduled_inputs.py` (34), among them a driver result equal to a hand loop stating every rate | +| FUT-003 | a paused replay carries its schedule; replay snapshot **schema 2** records its digest (upgrade from 1); restore refuses another schedule or none; what remains is derived from the last record taken | the payload round trip at every boundary, in the regression file above and in example 72's test | +| FUT-012 | `InstrumentRecord.last_trade_at` (instrument snapshot **schema 4**, upgrade from 3; outside the identity key); records after it skipped; requests refused before an order exists; `ContractSettlement` closes every position at the final price, cancels working orders, attributes the result; a run ending with an expired contract refused | `tests/integration/test_contract_expiry.py` (25: a future, an option exercised, assigned, worthless, written and expired, replay parity at every pause) | +| FUT-010 | `AssetType.BOND`; `alphalab.api.bond_schedule` from a `macro.Bond` | `tests/integration/test_bonds_on_the_path.py` (8), every figure against the bond's own cash flows | +| FUT-005 | `manifest_for_run(..., schedule=)` checks the schedule against the run's deliveries and records `schedule_digest`; a combined dataset's parts recorded as `dataset_parts`, checked to derive the combined identity; both listed by `external_requirements`; both in the identity only when present, so every v4.1 manifest keeps its id | `tests/integration/test_multi_asset_composition.py` (29) | +| FUT-008 | `alphalab.api.observations_from_indicator` | `tests/unit/test_api_macro_lift.py` (8, against `macro.known_as_of`) | +| FUT-006, FUT-007 | example 73: features from the study's own definitions, a linear model fitted on purged labels before a boundary, carried as declared parameters and state, traded after | `tests/integration/test_model_on_the_execution_path.py` (12) | +| FUT-009 | `alphalab.backtesting.algorithmic`: `ExecutionPlan` (per-asset `AlgorithmAssignment`s, an optional `VenueCheck`) on `BacktestEngine.run`, `ReplayBacktest.run` and the facade; `ExecutionRouting.ALGORITHMIC` holds every parent in the OMS and refuses stop, auction and immediate terms; `ExecutionPipeline.work_child` fills each child by the run's fill policy and cost model and settles it on the parent; the `AlgorithmBook` on the result, in replay schema 2, and the plan's id in the manifest | `tests/integration/test_algorithms_on_the_historical_path.py` (57), `test_execution_algorithm_example.py` (4), two manifest tests; example 74; `benchmarks/benchmark_algorithmic_execution.py` | +| FUT-013 | `alphalab.instrument.terms`: `OptionTerms` and `FutureTerms` on `InstrumentRecord.terms` (instrument schema 4, outside the identity key); `Exercise`, `Assignment`, `OptionExpiry` on the schedule and as run operations, through the portfolio's fill authority and each strategy's share; physical futures delivered at their final price; `AssetType.INDEX`; `session_close`, `settles_at` | `tests/integration/test_option_lifecycle.py` (58), `test_lifecycle_conventions.py` (9), `test_lifecycle_example.py` (4); example 75 | + +**Guards extended, none weakened.** The replay snapshot's field pin names +`PausedReplay.schedule`; the schema-upgrade suites pin instrument schema 4 and +replay schema 2 with an upgrade test each, the v4.1.0 payloads frozen in +`tests/fixtures` still restoring through them; the persisted-enum pin adds +`bond` and `ALGORITHMIC`, the replay field pin `PausedReplay.algorithms`; the +schema-constant guard knows `ALGORITHM_BOOK_SCHEMA`; the ADR-deferral classifier +covers ADR-0051's entries. + +**Mutation evidence.** Each guard named here was shown to fail against the +defect it guards, in a scratch copy of the package imported ahead of the +checkout (`-o pythonpath= --import-mode=append`): in the audit, the replay merging observations *after* closing the slice fails the backtest-replay parity test (after the workload was strengthened so a reading's size depends on the slices seen -- the first version did not distinguish the two orders, which this check found), and v4.1.0's `MarketView.bar` fails four bar tests and four acceptance tests; in the continuation, four mutants, each caught by the test written for it -- a cursor that counts the inputs after a record's instant as applied (`test_scheduled_inputs.py::test_a_replay_paused_anywhere_through_a_payload_keeps_every_input`), a restore that ignores the schedule's digest (`test_heterogeneous_inputs_on_every_driver.py::test_a_payload_resumed_with_another_schedule_or_none_is_refused`), a settlement that leaves orders working (`test_contract_expiry.py::test_an_order_still_working_at_settlement_is_cancelled`), and a manifest that trusts a dataset's stated parts (`test_multi_asset_composition.py::test_a_dataset_stating_parts_it_was_not_derived_from_is_refused`); in the FUT-009 milestone, eight mutants, each caught by `test_algorithms_on_the_historical_path.py` -- a child allowed to work more than its parent has left, a child trading at its release instant, a child outliving its slice, a parent ended without its reservation released, a child fill reported as completing the parent, algorithmic orders filled whole, a book trusting an altered payload, and the capability check skipped. Two of the eight survived the first test file -- the release-instant rule needed two records at one instant, and filling whole showed only under `SAME_EVENT` -- and the tests that catch them were added. In the FUT-013 milestone, twelve mutants, each caught by the lifecycle tests -- a European option exercised early, a put paying the call's payoff, a delivery financed or shorted silently, the exercise threshold ignored, a physical option closed at its intrinsic value (booking it twice), the deliverable's multiplier assumed one, partial-exercise ties given to the later strategy, delivered shares left at the strike, an index made tradable, the registry dropping terms, an exercise beyond the position; the delivered-shares mutant survived the first test, which read the mark only after later prices had re-marked the shares, and the test now reads the equity point at the delivery itself. + +--- + +## 7. The acceptance scenario + +`examples/72_compose_a_multi_asset_strategy.py`, held by +`tests/integration/test_multi_asset_composition.py` (29 tests) and run from +every built artifact by `tests/installed_smoke.py`. In the continuation its +inputs became one `RunSchedule`, its replay pauses through a JSON payload +restored with freshly built objects, its continuation uses `ScheduleCursor`, and +its manifest names the schedule and both datasets. + +**Inputs.** ACME (cash equity, XNYS, bars at 16:00 New York) and IDXM4 (an index +future, XCME, multiplier 50, variation margin, whole contracts, settlements at +17:00), 44 sessions each from two vendor files -- the future missing one +session -- read as one `CombinedDataset`. 46 social-media posts about ACME with a +`collected_at` column, among them a post written on 8 April and collected on +22 April, a post with no collection time, an exact repeat and an unreadable row, +and the file out of order. US CPI year-on-year with its period, release instant +and vintage: February (released before the data), March, March's revision on +24 April, April, and May (released after the data ends). + +**Strategy.** `CompositeSignal`, written against the protocol and built by a +class registry from parameters that name both information sets: five-session +momentum plus decayed sentiment, scaled to shares and capped; a short hedge in +the future while the latest-known CPI vintage is at or above 3.5, sized with the +contract's multiplier read from the instrument registry; the future left alone +when its last settlement is stale; orders to the next opening auction. Costs: +a six-role model (half-spread, slippage, commission, per-trade fee). +`FillTiming.NEXT_EVENT` and a 10% participation cap, so the result declares no +optimistic execution assumption. An order-size limit of 1,000 units refuses +twelve orders. + +| Property the brief requires | What the test establishes | +| --- | --- | +| 1. All eligible inputs consumed | 87 records (44 equity bars, 43 futures settlements) and 49 of 50 deliveries -- May's CPI is after the data -- each seen by the strategy in knowledge order; both instruments traded | +| 2. Late information only when available | every delivery seen at exactly its knowledge instant and never after a decision; the late post absent from every decision before 22 April 09:00 and present in every one after; every decision used the CPI vintage published by then, recomputed from the release rows alone; with the late post and the revision rewritten, every order and decision before availability is identical and the first difference is after it; **control**: the same schedule stamped at observation time moves decisions two weeks early, and the same check catches it | +| 3. Out-of-order and missing data explicit | out-of-order file delivered in knowledge order; repeat and unreadable rows rejected by line; the undated post counted (`unverified == 1`) and never delivered; the missing settlement leaves the future untouched that day; two datasets pricing one instrument at one instant refused | +| 4. Instrument and currency semantics | whole contracts; multiplier 50 on the position; notional never paid; one settlement currency; `equity == start + realized + unrealized - commission` and NAV agrees; a hedge instrument with no declared multiplier fails the strategy visibly and trades nothing | +| 5. Canonical path | every fill belongs to an OMS order and comes after the decision; no optimistic assumption | +| 6. Failures surfaced | twelve risk refusals in `result.state.risk.history`, each with its violation, none filled; no strategy failure in the clean run | +| 7. Lineage | the manifest's dataset is the combined version the run recorded, with each part recorded, checked and listed for a rerun; the parts reversed are another version and refused; the manifest names the schedule and refuses one missing the run's last delivery; the strategy fingerprint changes with the sentiment set | +| 8. Deterministic rerun | identical `RunDigest` in process, and the same `result_id` in fresh interpreters under two `PYTHONHASHSEED`s | +| 9. Replay agrees | the replay's orders, fills, valuation, deliveries and every decision equal the backtest's; a replay paused at **every** one of the 87 boundaries, written to JSON and restored with fresh objects, resumes to the uninterrupted replay's digest; restoring it with another schedule is refused; a backtest stored at **every** record and continued with freshly built objects reaches the same `result_id` | +| 10. Clean installation | run from the built wheel and sdist by `tests/installed_smoke.py` (section 8.1) | +| 11. v4.1.0 contracts | the full suite, the manifest diff (additive only), the v4.1.0 snapshot payloads upgrading, and the certificate (no figure moved) -- section 8 | + +**What it does not prove.** That sentiment or inflation predicts returns (the +prices are synthetic and the result loses money), that the strategy is +well designed, or that any of it behaves the same against a real venue. + +--- + +## 8. Validation + +### 8.1 Commands and results on the final tree + +Run in the checkout's dev venv (CPython 3.12.4) unless stated. + +| Gate | Command | Result | +| --- | --- | --- | +| Lint | `python -m ruff check .` | All checks passed | +| Format | `python -m ruff format --check .` | 1,310 files already formatted | +| Types | `python -m mypy .` | no issues in 1,208 source files | +| Tests | `python -m pytest -W error` | **10,020 passed, 3 failed** -- exactly the three version-keyed guards of section 8.2 (one of them the dev venv's stale metadata); 0 skipped | +| Examples | every `examples/[0-9]*.py` under `python -W error` | all 75 exit 0 | +| Certification | `python -W error docs/audit/scripts/certify_release.py --check` | 11 checks passed, matching the re-certified certificate (only the source digest, the host string and the API manifest's digest and export count moved; no determinism, parity, reproducibility or numerical figure) | +| Public API | `python docs/api/generate_public_api.py`, then the manifest tests | 2,667 exports, 35 shared names (2,628 at v4.1.0); additions and defaulted keywords or fields only, none removed or re-signed incompatibly (CHANGELOG `[Unreleased]` lists each) | +| Fresh 3.12 | the suite in a fresh venv from a clean mirror | CPython 3.12.4: **10,021 passed, 2 failed** (the two version-keyed guards; the metadata test passes) | +| CPython 3.13 | the suite on 3.13.17 (python-build-standalone, scratch) | CPython 3.13.17: **10,021 passed, 2 failed** (the same two) | +| CPython 3.14 | the suite on 3.14.9 | CPython 3.14.9: **10,021 passed, 2 failed** (the same two) | +| Build | `python -m build` in the clean mirror | `alphalab-4.1.0.tar.gz` and `alphalab-4.1.0-py3-none-any.whl` (labelled 4.1.0 until the version is set; kept in the scratchpad, `dist/` untouched) | +| Distributions | `python -m twine check --strict dist/*` | both PASSED | +| Installed artifacts | `python docs/audit/scripts/release_preflight.py distributions dist --version 4.1.0 --record dist/VERIFICATION-4.1.0.txt` (each artifact alone in a fresh environment, smoke-tested from outside the tree, examples 11 and 70 to 75 with the network refused) | **PASSED**: each artifact installed alone, `pip check` clean, 609 modules, 2,667 public names as the manifest records, examples 11 and 70 to 75 and the consumer contract run with the network refused; wheel `af0a86b8...6d36d`, sdist `73ba43f5...1a1` | +| Benchmarks | `benchmarks/benchmark_{backtesting,replay_engine,runtime,execution_pipeline,strategy_runtime}.py` under `-W error`, on this tree and on `git archive v4.1.0`, one after the other on one host; `benchmark_algorithmic_execution.py` on this tree | no regression on the final tree: judged backtest scaling 4.04x against v4.1.0's 4.03x, replay 1.03x the backtest on both, run layer +2.10% against +2.33% over the bare step, pipeline 4.04x against 4.05x, dispatch 273,635 against 274,714 hooks/s (within run-to-run noise); every parity line identical. `benchmark_algorithmic_execution.py`: judged 4.00x for a 4x workload, working orders in children 1.51x the run filling them whole (ceilings 6.0x and 4.0x) | +| Whitespace | `git diff --check` | clean | + +### 8.2 The three guards that wait for the version + +They read `alphalab.__version__`, still `4.1.0`, and fail by construction: + +* `tests/regression/test_api_changes_are_in_the_changelog.py::test_every_removed_or_changed_name_is_in_the_changelog` + compares the manifest with the newest history file older than the package's + version (4.0.0) and wants the changed names in the `[4.1.0]` entry -- the + released one, which is history and is not edited; +* `tests/regression/test_nothing_is_left_for_later.py::test_no_ledger_entry_is_assigned_beyond_this_release` + refuses ledger entries recorded for `v4.2.0` while the package says 4.1.0; +* `tests/unit/test_package_metadata.py::test_package_exposes_version` fails in the + dev venv only (stale editable metadata, as at baseline) and passes fresh. + +Choosing the version closes the first two (section 9's checklist); nothing else +depends on it. The version was not changed to make them pass. + +### 8.3 What was not run + +No hosted CI: Linux was not exercised (the branch is not pushed), nor CodeQL, +the preflight workflow or release validation; this stays outstanding until the +maintainer pushes. Windows remains untested (BDY-027). + +--- + +## 9. Versioning + +**Recommendation: release this candidate as v4.2.0.** It adds a public module +(`alphalab.backtesting.schedule`) and modules (`alphalab.backtesting.algorithmic`, +`alphalab.instrument.terms`), names in `alphalab.api`, `alphalab.portfolio`, +`alphalab.instrument`, `alphalab.runtime` and `alphalab.backtesting`, three +persisted enum members (`AssetType.BOND`, `AssetType.INDEX`, +`ExecutionRouting.ALGORITHMIC`), and +defaulted keywords or fields on existing bindings; it moves two persisted +schemas forward (instrument 3 -> 4, replay 1 -> 2), each with an upgrade that +reads every earlier payload, and leaves every v4.1 run `result_id` and manifest +id where it was. `docs/api/PUBLIC_API.md` says "a minor release adds and +corrects and does not break", and semantic versioning puts added functionality +in a minor release. A v4.1.1 label would mislabel it, which the brief forbids. +The alternative that keeps a v4.1.1: release KD-005 and DOC-012 alone as v4.1.1 +(a backward-compatible correction), and the rest as v4.2.0. + +**Checklist once the number is chosen** (each is mechanical; the guards above +check them): + +1. `alphalab/common/_version.py` -> the version. +2. `CHANGELOG.md`: `# [Unreleased] ...` -> `# [X.Y.Z] - date`. +3. `docs/api/history/4.1.0.json` (v4.1.0's manifest, from `git show + v4.1.0:docs/api/public_api.json`) added, with its row in `docs/api/history/README.md`. +4. `docs/api/PUBLIC_API.md` "At vX.Y.Z the manifest records ..." and the + release-facing version markers (`tests/regression/test_version_markers_agree.py` + lists them; `README.md`'s version and test count, `docs/README.md`, + `docs/INTEGRATION.md`'s pin range, `SECURITY.md`) updated. +5. The ledger's `release: "v4.2.0"` fields kept, or set to the chosen number. +6. `preflight.yml`'s release version, if a preflight is to run. +7. Re-certify (`certify_release.py`), rebuild, re-verify the artifacts. + +--- + +## 10. What remains + +| ID | Gap | Target | Class of work | +| --- | --- | --- | --- | +| FUT-014 | a strategy hook that instructs an exercise | v4.3 | a strategy-contract addition | +| FUT-016 | baskets, cash in lieu, quanto, Bermudan exercise | v4.3 | further declared terms | +| FUT-011 | a queue-position and fill-probability model | v5.0 | new capability; needs depth data (BDY-003) | +| FUT-015 | settlement-date (T+n) accounting | v5.0 | a portfolio schema step | +| FUT-017 | margin, collateral and forced liquidation | v5.0 | new capability; depends on FUT-015 | +| FUT-002 | a lossless typed form of a strategy configuration | v5.0 | a pipeline schema step (predates this audit) | + +**Release-blocking for the candidate:** the version decision (section 9) and +the three guards it closes; hosted CI on Linux and macOS for the pushed branch. +No engine gap above blocks it: each is accepted future work with its interim +behaviour stated. + +**Next, in dependency order:** (1) the maintainer chooses the number and the +checklist is applied; (2) the branch is pushed and hosted CI runs; (3) FUT-014, +which lets a strategy's own hooks take the exercise decisions FUT-013 now carries +out, on the same lifecycle path; (4) FUT-016, the contract terms the lifecycle +refuses today; (5) FUT-011; (6) FUT-015 then FUT-017, which change the +portfolio's payload and belong with a major release or a stated exception. + +**External (unchanged):** venue and data-vendor adapters, credentials, market +data, FX rates, holiday and reference data, settlement prices, text-to-sentiment +models, hosted isolation of user-supplied strategy code. + +**Remaining risks.** + +* *Correctness.* A strategy that reads bars through its context saw `None` + until this candidate; any host strategy written to treat `None` as "no data" + will now trade where it did not. A final settlement price is the host's; the + engine checks when it is applied and that it is not negative where that + cannot be right, not that it is the exchange's. A bond traded at clean prices + books no accrual. An exercise's or expiry's settlement value is the host's, + applied at the instant its event states. A delivery the book cannot cover + stops the run. A limit child fills by the simulator's resting rule, with no + queue model (FUT-011), so passive execution in a backtest is optimistic; a + child's fill reaches its strategy through the portfolio, not `on_fill`. +* *Numerical.* Unchanged: certified identities per host class for the + standalone engines (LIM-005); the construction solver is cubic (PRF-005). +* *Performance.* `MarketView.bar` is linear in the latest-bar index per call; a + feature evaluated inside a strategy recomputes its window per call. Neither is + measured at scale. +* *Reproducibility.* A run's own record names its last delivery, not its + schedule; the schedule is identified by the manifest when the producer passes + it. The certificate records this host as macOS 27.0.1, where v4.1.0's + recorded 26.6.2. diff --git a/docs/audit/PRE_V4_COMPLETION_LEDGER.yaml b/docs/audit/PRE_V4_COMPLETION_LEDGER.yaml index c9a9c5d2..12b9de01 100644 --- a/docs/audit/PRE_V4_COMPLETION_LEDGER.yaml +++ b/docs/audit/PRE_V4_COMPLETION_LEDGER.yaml @@ -5,7 +5,7 @@ ledger: baseline_version: "3.9.0" baseline_commit: "c616978a9eeab39479c54cd3ff60e9331658a8aa" audit_date: "2026-09-27" - status: "audit complete; v3.10.0 released (commit c93e9177146a6f6731bfa7068a5d8368656c8434); v3.11.0 released (commit a8a17b1f); v3.12.0 released (commit d3890781); v3.13.0 released (the commit tagged v3.13.0; every item assigned to it closed, the four assigned to v4.0.0 closed in it, every boundary and limitation reviewed with its reason, every limitation and deferral the release ADRs state classified, twenty-three findings added); nothing is assigned to a later release; v4.0.0 not started; v4.0.0 candidate: the v4.0 archaeology read every v2.0.0 to v3.13.0 deferral, non-goal and open item the pre-v4 inventory had not (TST-017) and classified each (HIS-001 to HIS-039, docs/audit/V4_HISTORICAL_INVENTORY.md); the v4.0 QA found and fixed PER-008, DAT-010, NUM-015, EXE-011, DOC-009 and API-007; one item is accepted future work (FUT-001), on the ROADMAP with acceptance criteria; v4.0.0 released (tag v4.0.0, commit 11d68dec); v4.1.0 candidate: FUT-001 delivered (replay pause and resume), LIM-005 and LIM-007 closed, LIM-004 and LIM-006 restated with v4.1 evidence, twelve findings added (PKG-002, PKG-003, TST-019 to TST-022, API-008, PER-010, DOC-011, BDY-027, FUT-002, REP-005)" + status: "audit complete; v3.10.0 released (commit c93e9177146a6f6731bfa7068a5d8368656c8434); v3.11.0 released (commit a8a17b1f); v3.12.0 released (commit d3890781); v3.13.0 released (the commit tagged v3.13.0; every item assigned to it closed, the four assigned to v4.0.0 closed in it, every boundary and limitation reviewed with its reason, every limitation and deferral the release ADRs state classified, twenty-three findings added); nothing is assigned to a later release; v4.0.0 not started; v4.0.0 candidate: the v4.0 archaeology read every v2.0.0 to v3.13.0 deferral, non-goal and open item the pre-v4 inventory had not (TST-017) and classified each (HIS-001 to HIS-039, docs/audit/V4_HISTORICAL_INVENTORY.md); the v4.0 QA found and fixed PER-008, DAT-010, NUM-015, EXE-011, DOC-009 and API-007; one item is accepted future work (FUT-001), on the ROADMAP with acceptance criteria; v4.0.0 released (tag v4.0.0, commit 11d68dec); v4.1.0 candidate: FUT-001 delivered (replay pause and resume), LIM-005 and LIM-007 closed, LIM-004 and LIM-006 restated with v4.1 evidence, twelve findings added (PKG-002, PKG-003, TST-019 to TST-022, API-008, PER-010, DOC-011, BDY-027, FUT-002, REP-005); v4.1.0 released (tag v4.1.0, commit 33e61d3e); v4.1.1 candidate, the future-readiness audit (release number pending the maintainer, recommended v4.2.0): KD-005, DOC-012, TST-023, OFE-027 and OFE-028 fixed or delivered; FUT-003 to FUT-010, FUT-012 and FUT-013 delivered (FUT-009 and FUT-013 by their own milestones); FUT-011 and FUT-014 to FUT-017 accepted as future work" findings: - id: "KD-001" category: "known_defect" @@ -5443,6 +5443,412 @@ findings: performance_impact: "neutral" verified: "v4.1: the new test fails with the fix reverted, with the checkout's version imported in place of the artifact's; mutation B06" v41_outcome: "Fixed and pinned; found by the mutation harness's baseline, not by a mutation." + # --- v4.1.1 candidate (future-readiness audit; release number pending the maintainer's decision) + - id: "KD-005" + release: "v4.2.0" + implementation_status: "implemented (v4.2.0 candidate)" + benchmark_status: "n/a" + category: "known_defect" + subsystem: "runtime; strategy" + module: "alphalab/runtime/context_views.py:MarketView.bar" + finding: "context.market.bar(asset_id), on the documented MarketViewProtocol, returned None for every asset in every run since v2.10: MarketView.bar looked the bare asset_id up in MarketState.latest_bars, which MarketEngine.publish_bar keys _, so no key ever matched. No test read a bar through a strategy context." + evidence: "v4.1.1 future-readiness audit: found writing the acceptance scenario, whose hedge sizes a futures position from the future's last settlement and never hedged; reproduced with a strategy reading its own bar (None on v4.1.0)." + severity: "high" + current_state: "Since v2.10 (6861338, populate strategy context)." + required_change: "FIX: find the asset's bars by their key prefix and return the most recent, the longer interval on a tie." + rationale: "A documented accessor that silently answers nothing makes a strategy act as if data were missing." + test_status: "tests/regression/test_strategy_context_reads_bars.py::test_a_strategy_reads_the_bar_it_is_dispatched_on_through_its_context" + documentation_status: "docs/audit/STRATEGY_CAPABILITY_MATRIX.md 2.1; docs/audit/FUTURE_READINESS_V4_1_1.md" + api_impact: "none (an existing method corrected)" + architecture_impact: "none" + performance_impact: "linear in the latest-bar index per call, as MarketView.prices is" + verified: "v4.1.1: four of the five new tests fail against v4.1.0's lookup in a scratch copy" + candidate_outcome: "Fixed and pinned." + - id: "DOC-012" + release: "v4.2.0" + implementation_status: "implemented (v4.2.0 candidate)" + benchmark_status: "n/a" + category: "documentation" + subsystem: "reinforcement_learning" + module: "alphalab/reinforcement_learning/__init__.py; alphalab/reinforcement_learning/environment.py" + finding: "Both docstrings still described the PortfolioEngine.apply_fill realized-P&L double count as present and unfixed, and the pipeline as marking positions only on fills; the defect was fixed in v2.0.0 (CHANGELOG D1), mark-to-market exists since v2.1, and the package's own test asserts the corrected figure." + evidence: "v4.1.1 audit: CHANGELOG 2.0.0 D1; tests/regression/test_close_fill_cash_accounting.py; a direct check that process_quote marks the environment's position at the step price." + severity: "low" + current_state: "Since the RL engine was added (3f5f1a3)." + required_change: "FIX: say what is true now and keep the history as history." + rationale: "A docstring that reports a fixed defect as live misleads every reader of the package." + test_status: "tests/unit/reinforcement_learning/test_reinforcement_learning.py::test_environment_round_trip_realizes_correct_pnl" + documentation_status: "docs/audit/STRATEGY_CAPABILITY_MATRIX.md 2.19" + api_impact: "none" + architecture_impact: "none" + performance_impact: "neutral" + verified: "v4.1.1 audit" + candidate_outcome: "Corrected." + - id: "OFE-027" + release: "v4.2.0" + implementation_status: "implemented (v4.2.0 candidate)" + benchmark_status: "n/a" + category: "optional_feature_evolution" + subsystem: "backtesting; runtime; api" + module: "alphalab/backtesting/replay.py; alphalab/backtesting/engine.py; alphalab/api.py; alphalab/runtime/live.py" + finding: "External information reached a strategy on one driver only: BacktestEngine.run merged a delivery schedule with its records (OFE-009); ReplayBacktest.run took none, so the replay of a strategy acting on news or a macro release was a different run from its backtest; alphalab.api.backtest and replay took none; LiveSession had no call that delivered an observation and routed what it produced." + evidence: "v4.1.1 audit of every driver against OFE-009." + severity: "medium" + current_state: "Since v3.12 (OFE-009)." + required_change: "FIX: one schedule (alphalab.backtesting.schedule) applied by both historical drivers through ScheduleCursor; observations and the other timed inputs on ReplayBacktest.run and the facade; a paused replay carries its schedule and its payload records the schedule's digest (FUT-003); LiveSession.deliver_observation." + rationale: "Backtest and replay are one path by construction (ADR-0010); information is part of the path's input." + test_status: "tests/regression/test_heterogeneous_inputs_on_every_driver.py::test_a_replay_delivers_observations_exactly_as_the_backtest_does" + documentation_status: "docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md" + api_impact: "additive: keyword-only observations and schedule on ReplayBacktest.run, alphalab.api.backtest and replay; schedule on BacktestEngine.run; PausedReplay.schedule (defaulted); LiveSession.deliver_observation; alphalab.backtesting.schedule" + architecture_impact: "local" + performance_impact: "neutral (with no observations given the loops are the v4.1.0 loops)" + verified: "v4.1.1: 40 tests; a scratch copy merging after the slice closes fails the parity test" + candidate_outcome: "Delivered for the replay, the facade and the live driver." + - id: "OFE-028" + release: "v4.2.0" + implementation_status: "implemented (v4.2.0 candidate)" + benchmark_status: "n/a" + category: "optional_feature_evolution" + subsystem: "api; lifecycle" + module: "alphalab/api.py (CombinedDataset, combine_datasets, combined_market_dataset)" + finding: "A run could name exactly one dataset. A dataset is one series kind from one source, so a strategy trading an equity and a future, or reading two providers, needed a hand-merged MarketDataset carrying an id its author typed, which no manifest could check." + evidence: "v4.1.1 audit: alphalab.api.to_market_dataset and lifecycle.manifest_for_run take one dataset." + severity: "medium" + current_state: "Since v3.1 (ADR-0036)." + required_change: "FIX: a combined dataset that keeps each part's provenance and derives one identity from the parts' versions and content hashes in order; records merged by time, part and position with nothing aligned; one instrument's price from two parts at one instant refused; usable by manifest_for_run as any dataset is." + rationale: "Lineage that holds for one input and not for two is not lineage a multi-asset study can use." + test_status: "tests/unit/test_api_combined_datasets.py::test_the_identity_is_derived_from_the_parts_in_order" + documentation_status: "docs/audit/STRATEGY_CAPABILITY_MATRIX.md 2.13" + api_impact: "additive: five names in alphalab.api" + architecture_impact: "local" + performance_impact: "linear in the parts' records" + verified: "v4.1.1: 8 unit tests; the acceptance scenario's manifest checks it and refuses the parts reversed" + candidate_outcome: "Delivered." + - id: "TST-023" + release: "v4.2.0" + implementation_status: "implemented (v4.2.0 candidate)" + benchmark_status: "n/a" + category: "testing" + subsystem: "core" + module: "tests/regression/test_v39_invariants.py" + finding: "The compatibility invariant asserted that 600 seeded random draws reach every Compatibility status, and a random declaration is almost never compatible: on v4.1.0 two draws in 600 were under its seed and none under seeds 1, 2 or 3, so COMPATIBLE was reached by the luck of the seed. Adding AssetType.BOND moved the stream (the generator samples list(AssetType)) and the test failed although check_compatibility had not changed." + evidence: "v4.1.1 continuation: the full suite after FUT-010; status counts measured on v4.1.0 and on the candidate under four seeds." + severity: "low" + current_state: "Since v3.9." + required_change: "FIX: each draw also checks a declaration built to offer everything the requirements need, which must be COMPATIBLE with every check SUPPORTED; the every-status assertion is kept, now reached by construction. The new test passes against v4.1.0's package too." + rationale: "A property test whose coverage claim rests on a seed fails on an unrelated change and says nothing about the property." + test_status: "tests/regression/test_v39_invariants.py::test_compatible_means_every_check_supported_and_nothing_else_does" + documentation_status: "docs/audit/FUTURE_READINESS_V4_1_1.md" + api_impact: "none" + architecture_impact: "none" + performance_impact: "neutral" + verified: "v4.1.1 continuation: 600 constructed declarations compatible; passes on v4.1.0 and on the candidate" + candidate_outcome: "Fixed; the converse property is now checked on every draw." + - id: "FUT-003" + category: "future_work" + subsystem: "backtesting" + module: "alphalab/backtesting/replay_snapshot.py" + finding: "A paused replay with observation deliveries outstanding cannot be written durably: replay schema 1 records the run's observation cursor but not the schedule." + evidence: "v4.1.1 future-readiness audit (docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md)." + severity: "low" + current_state: "As of the v4.1.1 candidate." + required_change: "DELIVERED (v4.2.0 candidate): replay snapshot schema 2 records the digest of the replay's RunSchedule; restore takes the schedule back and refuses another or none; what remains is derived from the last record taken, so nothing is applied twice or lost; schema 1 payloads upgrade with no schedule." + rationale: "Deferred work kept where it is tracked is work." + release: "v4.2.0" + implementation_status: "implemented (v4.2.0 candidate)" + test_status: "tests/regression/test_heterogeneous_inputs_on_every_driver.py::test_a_replay_with_information_outstanding_resumes_from_its_payload" + documentation_status: "docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md; docs/audit/FUTURE_READINESS_V4_1_1.md" + benchmark_status: "n/a" + api_impact: "additive (a schema step)" + architecture_impact: "local" + performance_impact: "neutral" + verified: "v4.1.1 continuation: the 24-boundary payload round trip fails on schema 1, which refused the capture" + candidate_outcome: "Delivered: a paused replay with information outstanding round-trips at every boundary." + - id: "FUT-004" + category: "future_work" + subsystem: "backtesting; runtime" + module: "alphalab/backtesting/engine.py; alphalab/backtesting/replay.py" + finding: "The historical drivers take market records and observations only: no FX rate schedule, no funding of a second settlement currency, no timer schedule, and no scheduled cash flows or corporate actions." + evidence: "v4.1.1 future-readiness audit (docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md)." + severity: "low" + current_state: "As of the v4.1.1 candidate." + required_change: "DELIVERED (v4.2.0 candidate): alphalab.backtesting.schedule -- RunSchedule and ScheduleCursor -- carry rates, funding, cash flows, per-unit distributions, funding rates, splits, timers and contract settlements on BacktestEngine.run, ReplayBacktest.run and the facade, each at its instant (before or after the records of its instant), the rate table converting every step; RunEngine.fund added." + rationale: "Deferred work kept where it is tracked is work." + release: "v4.2.0" + implementation_status: "implemented (v4.2.0 candidate)" + test_status: "tests/integration/test_scheduled_inputs.py::test_the_driver_equals_a_loop_that_states_every_rate_by_hand" + documentation_status: "docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md; docs/audit/FUTURE_READINESS_V4_1_1.md" + benchmark_status: "n/a" + api_impact: "additive" + architecture_impact: "local" + performance_impact: "neutral" + verified: "v4.1.1 continuation: driver result equal to a RunEngine loop with every rate stated by hand; 34 tests" + candidate_outcome: "Delivered: a two-currency book, timers, dividends, splits and funding through the drivers, equal to the hand loop." + - id: "FUT-005" + category: "future_work" + subsystem: "lifecycle; runtime" + module: "alphalab/lifecycle/reproducibility.py; alphalab/runtime/run.py" + finding: "A run records how many observations it delivered and the last one's delivery id, not the sets it was given; a combined dataset's manifest names the combined content hash and lists its parts only in CombinedProvenance.parts." + evidence: "v4.1.1 future-readiness audit (docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md)." + severity: "low" + current_state: "As of the v4.1.1 candidate." + required_change: "DELIVERED (v4.2.0 candidate): manifest_for_run takes the run's schedule, checks it holds the run's deliveries, and records its digest in the manifest identity only when given (so earlier manifests keep their ids); external_requirements lists it. A run over a combined dataset records each part (ReproducibilityManifest.dataset_parts), checked to derive the combined version and content hash, rendered into the identity only when present, and listed part by part by external_requirements." + rationale: "Deferred work kept where it is tracked is work." + release: "v4.2.0" + implementation_status: "implemented (v4.2.0 candidate)" + test_status: "tests/integration/test_multi_asset_composition.py::test_the_manifest_names_the_schedule_and_refuses_one_the_run_was_not_given" + documentation_status: "docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md; docs/audit/FUTURE_READINESS_V4_1_1.md" + benchmark_status: "n/a" + api_impact: "additive" + architecture_impact: "local" + performance_impact: "neutral" + verified: "v4.1.1 continuation: a schedule missing the run's last delivery is refused; parts reordered on a manifest fail verify_manifest, and a dataset stating parts it was not derived from is refused; manifests built without either keep their v4.1 ids" + candidate_outcome: "Delivered. A run's record still names only its last delivery; the manifest is where the schedule is identified." + - id: "FUT-006" + category: "future_work" + subsystem: "factor_library; strategy" + module: "alphalab/factor_library; alphalab/strategy" + finding: "A feature computed in a study and the same feature computed inside a strategy are two pieces of code; nothing checks they agree." + evidence: "v4.1.1 future-readiness audit (docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md)." + severity: "low" + current_state: "As of the v4.1.1 candidate." + required_change: "DELIVERED (v4.2.0 candidate) as evidence: a strategy evaluates the study's FeatureDefinitions with compute_feature over the closes it has been shown, and equals the study panel at every instant (examples/73_train_and_trade_a_model.py). No incremental evaluator: each evaluation recomputes the window history, linear per call." + rationale: "Deferred work kept where it is tracked is work." + release: "v4.2.0" + implementation_status: "implemented (v4.2.0 candidate)" + test_status: "tests/integration/test_model_on_the_execution_path.py::test_the_strategy_computes_the_studys_features_exactly" + documentation_status: "docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md; docs/audit/FUTURE_READINESS_V4_1_1.md" + benchmark_status: "n/a" + api_impact: "additive" + architecture_impact: "local" + performance_impact: "neutral" + verified: "v4.1.1 continuation: example 73 and its test (111 instants equal)" + candidate_outcome: "Delivered as a tested pattern; an incremental evaluator remains an optimisation, not a correctness gap." + - id: "FUT-007" + category: "future_work" + subsystem: "ml; strategy" + module: "alphalab/ml; alphalab/deep_learning; examples" + finding: "No test or example trains a model on a window before a boundary and uses it for inference inside a strategy on the execution path." + evidence: "v4.1.1 future-readiness audit (docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md)." + severity: "low" + current_state: "As of the v4.1.1 candidate." + required_change: "DELIVERED (v4.2.0 candidate) as evidence: examples/73 fits a linear model only on samples whose label was known before a boundary, carries it as the strategy's declared parameters and state, trades it after the boundary, restores it exactly and reproduces it under two hash seeds." + rationale: "Deferred work kept where it is tracked is work." + release: "v4.2.0" + implementation_status: "implemented (v4.2.0 candidate)" + test_status: "tests/integration/test_model_on_the_execution_path.py::test_prices_from_the_boundary_on_change_neither_the_model_nor_an_earlier_decision" + documentation_status: "docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md; docs/audit/FUTURE_READINESS_V4_1_1.md" + benchmark_status: "n/a" + api_impact: "none" + architecture_impact: "local" + performance_impact: "neutral" + verified: "v4.1.1 continuation: example 73 and its test, including a counterfactual on every price after the boundary" + candidate_outcome: "Delivered." + - id: "FUT-008" + category: "future_work" + subsystem: "macro; alt_data" + module: "alphalab/macro" + finding: "alphalab.macro's indicator records do not convert to ExternalObservation, so the macro engine's series and the delivery path do not share records." + evidence: "v4.1.1 future-readiness audit (docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md)." + severity: "low" + current_state: "As of the v4.1.1 candidate." + required_change: "DELIVERED (v4.2.0 candidate): alphalab.api.observations_from_indicator lifts alphalab.macro releases into a point-in-time ObservationSet -- observed at the reference period, known at the release date, the vintage numbered by release order -- and refuses a series whose revision flags contradict it." + rationale: "Deferred work kept where it is tracked is work." + release: "v4.2.0" + implementation_status: "implemented (v4.2.0 candidate)" + test_status: "tests/unit/test_api_macro_lift.py::test_what_is_known_at_any_instant_agrees_with_the_macro_engine" + documentation_status: "docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md; docs/audit/FUTURE_READINESS_V4_1_1.md" + benchmark_status: "n/a" + api_impact: "additive" + architecture_impact: "local" + performance_impact: "neutral" + verified: "v4.1.1 continuation: 8 unit tests against alphalab.macro.known_as_of" + candidate_outcome: "Delivered." + - id: "FUT-009" + category: "future_work" + subsystem: "execution; backtesting" + module: "alphalab/execution/algorithms.py; alphalab/backtesting/algorithmic.py; alphalab/runtime/execution_pipeline.py" + finding: "Execution algorithms (TWAP, VWAP, participation) slice orders on the live path only; a backtest fills at the parent level." + evidence: "v4.1.1 future-readiness audit (docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md)." + severity: "low" + current_state: "As of the v4.1.1 candidate." + required_change: "DELIVERED (v4.2.0 candidate): an ExecutionPlan given to BacktestEngine.run, ReplayBacktest.run or the facade routes the run ALGORITHMIC (a new ExecutionRouting member): the pipeline accepts and holds each parent -- risk judged it whole, allocation reserved it -- and refuses stop, auction and immediate terms before an order exists; alphalab.backtesting.algorithmic adopts each parent at its asset's next event (the plan's window from the instant it was placed, its own order type and limit), releases children by release_children at the asset's events, checks each against a declared VenueCheck, and has ExecutionPipeline.work_child simulate each by the run's fill policy, simulator and cost model, its fill settled on the parent through the canonical report path (OMS partial fill, portfolio, reservation, attributed trade record); a child trades only at a later instant than its release under NEXT_EVENT, and never outside its slice or window; the parent is ended when its algorithm finishes, its reservation released once. The book persists in replay snapshot schema 2 and is checked against its plan; manifest_for_run records the plan's id. The audit's completion criterion -- equal to the PaperBroker route fill for fill -- is restated: a PaperBroker sees no market data and fills a market child at the parent's reference price, so a backtest equal to it in price would ignore each child's own bar; the criterion held is the same children, by id, quantity and instant, as route_child_order sends a PaperBroker, with fills priced by the run at each child's bar." + rationale: "Deferred work kept where it is tracked is work." + release: "v4.2.0" + implementation_status: "implemented (v4.2.0 candidate)" + test_status: "tests/integration/test_algorithms_on_the_historical_path.py::test_a_twap_parent_trades_as_four_children_that_reconcile_to_it" + benchmark_status: "benchmarks/benchmark_algorithmic_execution.py" + documentation_status: "docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md; docs/audit/FUTURE_READINESS_V4_1_1.md; examples/74_evaluate_an_execution_algorithm.py" + api_impact: "additive (a routing member, a module, a pipeline method, keyword-only parameters)" + architecture_impact: "local" + performance_impact: "a run given no plan is unchanged; one given a plan is linear in records, about 1.5x the run filling its orders whole (benchmark)" + verified: "v4.1.1 FUT-009 milestone: 57 integration tests through both drivers (TWAP, VWAP, slicing, iceberg and participation; partial fills, no liquidity, rejection, capability refusal, a split cancelling the parent, incomplete at the end of the data, both fill timings, attribution of a netted parent, replay parity, a replay paused at every boundary resumed from its payload, an altered book refused) and two manifest tests; eight mutants of the guards each caught" + candidate_outcome: "Delivered for TWAP, VWAP, slicing, iceberg and participation with LEAVE_UNFILLED; participation with COMPLETE_AT_END is refused by the plan." + - id: "FUT-010" + category: "future_work" + subsystem: "core; conventions; portfolio" + module: "alphalab/core/enums.py; alphalab/conventions/economics.py; alphalab/macro/bond.py" + finding: "There is no bond instrument type and no accrual on the execution path; bond analytics exist in alphalab.macro.bond only." + evidence: "v4.1.1 future-readiness audit (docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md)." + severity: "low" + current_state: "As of the v4.1.1 candidate." + required_change: "DELIVERED (v4.2.0 candidate): AssetType.BOND, booked by declared economics (its multiplier on the quoted price) with its maturity as last_trade_at; alphalab.api.bond_schedule turns a Bond's terms into per-unit coupons and a redemption settlement. Accrual is carried by trading full (dirty) prices; a separate accrued-interest ledger entry is not booked." + rationale: "Deferred work kept where it is tracked is work." + release: "v4.2.0" + implementation_status: "implemented (v4.2.0 candidate)" + test_status: "tests/integration/test_bonds_on_the_path.py::test_coupons_and_redemption_reach_the_holder_and_the_books_balance" + documentation_status: "docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md; docs/audit/FUTURE_READINESS_V4_1_1.md" + benchmark_status: "n/a" + api_impact: "additive (a persisted enum member)" + architecture_impact: "local" + performance_impact: "neutral" + verified: "v4.1.1 continuation: coupons and redemption equal the bond's own cash flows; 8 tests" + candidate_outcome: "Delivered; clean-price booking with accrued interest as a separate cash leg is not modelled (stated)." + - id: "FUT-011" + category: "future_work" + subsystem: "execution" + module: "alphalab/execution" + finding: "No queue-position or fill-probability model: a resting limit order fills by the simulator's rule." + evidence: "v4.1.1 future-readiness audit (docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md)." + severity: "low" + current_state: "As of the v4.1.1 candidate." + required_change: "FUTURE (ROADMAP 'Future work'). Limitation: passive fills are decided without a queue. Impact: a market-making or passive-execution backtest is optimistic about passive fills. Reason for deferral: a queue model needs depth data and a calibrated model; that is new engine capability, and depth is not a strategy hook (BDY-003). Interim safe behaviour: treat passive fills as an optimistic assumption and size conclusions accordingly. Target horizon: v5.0 Dependencies: depth data; BDY-003. Completion criteria: a declared queue model decides passive fills, named in the result's execution assumptions, with its own tests." + rationale: "Deferred work kept where it is tracked is work." + release: "v4.2.0" + implementation_status: "accepted as future work (v5.0, proposed by the v4.1.1 audit, pending the maintainer's decision): ROADMAP Future work FUT-011" + test_status: "n/a" + documentation_status: "ROADMAP.md (Future work); docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md" + benchmark_status: "n/a" + api_impact: "additive" + architecture_impact: "local" + performance_impact: "neutral" + target_horizon: "v5.0" + verified: "v4.1.1 audit" + candidate_outcome: "Accepted as future work, with the decision it needs named." + - id: "FUT-012" + category: "future_work" + subsystem: "instrument; portfolio; runtime" + module: "alphalab/conventions/economics.py; alphalab/portfolio/engine.py; alphalab/runtime/execution_pipeline.py" + finding: "Nothing on the execution path knows a contract's expiry: an InstrumentRecord declares a multiplier and a settlement model, not a last trading day, so a future or option held past expiry is not closed, exercised, assigned or cash-settled -- it stays marked at its last price unless the strategy trades it out. alphalab.futures and alphalab.options model expiries for research only." + evidence: "v4.1.1 future-readiness audit: no expiry, exercise or assignment handling in alphalab/runtime/execution_pipeline.py or alphalab/portfolio/engine.py." + severity: "medium" + current_state: "Since instrument economics reached the path (v3.11, ACC-005)." + required_change: "DELIVERED (v4.2.0 candidate) for cash settlement: InstrumentRecord.last_trade_at (instrument schema 4), records after it skipped, requests after it refused before an order exists, ContractSettlement closing every position at the final price (a future's last variation, an option's intrinsic value, zero for one expiring worthless) with working orders cancelled and the settlement attributed, and a run that ends holding an expired contract refused. Physical delivery is FUT-013." + rationale: "A specialised instrument must fail explicitly rather than be simulated with a default that is wrong." + release: "v4.2.0" + implementation_status: "implemented (v4.2.0 candidate)" + test_status: "tests/integration/test_contract_expiry.py::test_a_future_held_to_expiry_is_closed_at_its_final_settlement" + benchmark_status: "n/a" + documentation_status: "docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md; docs/audit/FUTURE_READINESS_V4_1_1.md" + api_impact: "additive (an instrument field and a portfolio event)" + architecture_impact: "local" + performance_impact: "neutral" + verified: "v4.1.1 continuation: 25 tests, a written option assigned among them; an option expiring worthless needed final_settlement on PortfolioEngine.apply_fill, which refused a zero price" + candidate_outcome: "Delivered for cash settlement." + - id: "FUT-013" + category: "future_work" + subsystem: "instrument; portfolio; runtime" + module: "alphalab/instrument/terms.py; alphalab/runtime/execution_pipeline.py; alphalab/backtesting/schedule.py; alphalab/runtime/calendars.py" + finding: "Physical delivery and early exercise are not modelled: a future delivering its commodity, or an equity option exercised into shares, has no settlement path, and settling an American option before its last trading instant is refused; ContractSettlement is cash settlement at expiry only." + evidence: "v4.1.1 continuation, implementing FUT-012." + severity: "low" + current_state: "As of the v4.2.0 candidate, before the FUT-013 milestone." + required_change: "DELIVERED (v4.2.0 candidate): InstrumentRecord.terms (outside the identity key; instrument schema 4, unreleased, so no further step) declares OptionTerms -- call or put, strike, AMERICAN or EUROPEAN exercise with its window (exercisable_from, expires_at at or after last_trade_at), CASH or PHYSICAL settlement, one Deliverable in any quantity per contract, an AutomaticExercise threshold or none -- or FutureTerms (CASH, or PHYSICAL with a Deliverable). Exercise, Assignment and OptionExpiry are schedule entries (EXERCISE, ASSIGNMENT, EXPIRY, after the instant's records, in that order before SETTLEMENT) and RunEngine/ExecutionPipeline operations: inside the contract's window, against the book's long or short contracts, a cash-settled contract pays max(S x units x m_deliverable / m - K, 0) per unit of price against a supplied settlement value, a physical one closes the contracts at nothing and moves the deliverable against strike x multiplier through the portfolio's fill authority, marked at the market's price; each strategy's share closes and receives its share of the delivery (largest remainder, refused across opposite sides); a physical future delivers at its final price after its last variation. Refused before anything moves: an undeclared or other-currency deliverable, a delivery leaving the book short when shorting is not allowed, a payment the cash cannot cover (no financing modelled), a cash exercise without a settlement value, an exercise outside the window or beyond the position, orders working in the contract. AssetType.INDEX is a reference value cash options settle against, refused for trading. alphalab.runtime.calendars.session_close and settles_at turn a declared MarketCalendar and SettlementRule into lifecycle instants. Assignment before expiry is an input, never drawn; at expiry the declared automatic-exercise rule exercises longs and assigns shorts." + rationale: "A contract's lifecycle is the contract's, declared; the engine carries it out through the canonical authorities rather than the caller computing it." + release: "v4.2.0" + implementation_status: "implemented (v4.2.0 candidate)" + test_status: "tests/integration/test_option_lifecycle.py::test_an_exercised_call_takes_the_shares_and_pays_the_strike" + benchmark_status: "n/a" + documentation_status: "docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md; docs/audit/FUTURE_READINESS_V4_1_1.md; examples/75_exercise_assign_and_deliver.py" + api_impact: "additive (instrument terms, lifecycle events, schedule kinds, an AssetType member, pipeline and run operations)" + architecture_impact: "local" + performance_impact: "neutral: a run with no terms or lifecycle entries takes none of the new paths" + verified: "v4.1.1 FUT-013 milestone: 58 lifecycle tests through BacktestEngine and ReplayBacktest (cash calls and puts long and short in, at and out of the money; thresholds; physical calls and puts; early exercise in part; early assignment; adjusted deliverables; contract units; options on futures; windows; refusals; attribution; physical and cash futures; replay parity and a replay paused at every boundary; terms through a registry payload), 9 convention tests (four synthetic market profiles, daylight saving, a Sunday-to-Thursday market with a lunch break and a holiday, T+n by the market's calendar, an option settling in another currency valued at the rate in force), 4 for example 75; the cash payoffs equal alphalab.options.expiration.resolve_expiration's; twelve mutants of the guards each caught" + candidate_outcome: "Delivered for American and European options settled in cash or by one deliverable, and for futures settled in cash or by delivery; the support boundary is FUT-014 to FUT-017." + - id: "FUT-014" + category: "future_work" + subsystem: "strategy; runtime" + module: "alphalab/strategy/protocol.py; alphalab/runtime/execution_pipeline.py" + finding: "A strategy cannot instruct an exercise from its hooks: an exercise is a scheduled input or a RunEngine call, so an exercise decided from data during the run is written as a stated policy in advance." + evidence: "v4.1.1 FUT-013 milestone: found implementing the derivative lifecycle." + severity: "low" + current_state: "As of the v4.2.0 candidate." + required_change: "FUTURE (ROADMAP 'Future work'). Limitation: no strategy hook returns a lifecycle instruction. Impact: an exercise decision must be stated before the run (example 75's policy reads only published prices), not taken inside it. Reason for deferral: a strategy affects the world only through intents, and an instruction channel is a change to the strategy contract and its replay. Interim safe behaviour: state the exercise policy as a function over point-in-time data and schedule its instructions; the engine checks every one against the contract's window and the book. Target horizon: v4.3. Dependencies: FUT-013 (delivered). Completion criteria: a strategy hook returns an exercise instruction applied at its event's instant after the instant's records, time-gated and refused like a scheduled one, identical in backtest and replay and across a pause." + rationale: "Deferred work kept where it is tracked is work." + release: "v4.2.0" + implementation_status: "accepted as future work (v4.3, proposed by the FUT-013 milestone, pending the maintainer's decision): ROADMAP Future work FUT-014" + test_status: "n/a" + documentation_status: "ROADMAP.md (Future work); docs/audit/STRATEGY_CAPABILITY_MATRIX.md" + benchmark_status: "n/a" + api_impact: "additive" + architecture_impact: "local" + performance_impact: "neutral" + target_horizon: "v4.3" + verified: "v4.1.1 FUT-013 milestone" + candidate_outcome: "Accepted as future work, with the decision it needs named." + - id: "FUT-015" + category: "future_work" + subsystem: "portfolio" + module: "alphalab/portfolio; alphalab/conventions/settlement.py" + finding: "Cash and positions move at the instant of a fill or a lifecycle event; a market's T+n settlement lag is computed by the conventions package and settles_at, but the portfolio keeps no unsettled-cash or pending-delivery ledger." + evidence: "v4.1.1 FUT-013 milestone: found implementing the derivative lifecycle." + severity: "low" + current_state: "As of the v4.2.0 candidate." + required_change: "FUTURE (ROADMAP 'Future work'). Limitation: trade-date accounting only: buying power is the settled and unsettled cash together. Impact: a strategy may spend cash a market would not yet have settled, and a delivery lands on the trade instant unless its event is scheduled at the settlement instant. Reason for deferral: a pending-settlement ledger changes the portfolio payload and every run's record. Interim safe behaviour: schedule a lifecycle event at settles_at's instant to model its lag; treat buying power as optimistic. Target horizon: v5.0. Dependencies: a portfolio schema step; FUT-002's pipeline step if they are taken together. Completion criteria: fills and deliveries are booked pending until their declared settlement date by the market's calendar, buying power excludes unsettled cash, and every earlier payload reads as settled." + rationale: "Deferred work kept where it is tracked is work." + release: "v4.2.0" + implementation_status: "accepted as future work (v5.0, proposed by the FUT-013 milestone, pending the maintainer's decision): ROADMAP Future work FUT-015" + test_status: "n/a" + documentation_status: "ROADMAP.md (Future work); docs/audit/STRATEGY_CAPABILITY_MATRIX.md" + benchmark_status: "n/a" + api_impact: "additive" + architecture_impact: "local" + performance_impact: "neutral" + target_horizon: "v5.0" + verified: "v4.1.1 FUT-013 milestone" + candidate_outcome: "Accepted as future work, with the decision it needs named." + - id: "FUT-016" + category: "future_work" + subsystem: "instrument" + module: "alphalab/instrument/terms.py" + finding: "Contract terms cannot state a deliverable of several instruments or with cash in lieu, a consideration in another currency than the deliverable, or an exercise style beyond American and European (Bermudan dates, daily exercise cut-offs); and a corporate action on the deliverable does not adjust an option's declared terms -- the caller re-declares the adjusted contract." + evidence: "v4.1.1 FUT-013 milestone: found implementing the derivative lifecycle." + severity: "low" + current_state: "As of the v4.2.0 candidate." + required_change: "FUTURE (ROADMAP 'Future work'). Limitation: such contracts are refused at declaration or at their event, never approximated. Impact: an adjusted contract delivering a basket after a merger, a quanto option or a Bermudan swaption cannot be held through its lifecycle. Reason for deferral: each is a further declared term with its own validation and settlement, not a branch. Interim safe behaviour: close such a contract before its last trading instant; the run refuses to finish holding it. Target horizon: v4.3. Dependencies: FUT-013 (delivered). Completion criteria: a deliverable list with a cash component, a declared consideration currency with its FX timing, and a declared set of exercise dates are each settled through the portfolio's fill authority and attributed, and round-trip through the registry payload; a split or merger on a deliverable adjusts the contracts on it by a declared adjustment rule." + rationale: "Deferred work kept where it is tracked is work." + release: "v4.2.0" + implementation_status: "accepted as future work (v4.3, proposed by the FUT-013 milestone, pending the maintainer's decision): ROADMAP Future work FUT-016" + test_status: "n/a" + documentation_status: "ROADMAP.md (Future work); docs/audit/STRATEGY_CAPABILITY_MATRIX.md" + benchmark_status: "n/a" + api_impact: "additive" + architecture_impact: "local" + performance_impact: "neutral" + target_horizon: "v4.3" + verified: "v4.1.1 FUT-013 milestone" + candidate_outcome: "Accepted as future work, with the decision it needs named." + - id: "FUT-017" + category: "future_work" + subsystem: "risk; portfolio" + module: "alphalab/risk; alphalab/runtime/execution_pipeline.py" + finding: "No margin, collateral or forced-liquidation model: an assignment or delivery the book cannot cover raises, and the run stops, rather than calling margin or liquidating." + evidence: "v4.1.1 FUT-013 milestone: found implementing the derivative lifecycle." + severity: "low" + current_state: "As of the v4.2.0 candidate." + required_change: "FUTURE (ROADMAP 'Future work'). Limitation: insufficient cash or inventory for a delivery stops the run with the shortfall named. Impact: a short-option or physical-delivery strategy is tested only where the book can always cover; a margin call's path is not simulated. Reason for deferral: a margin and liquidation model is new engine capability, and venue-specific. Interim safe behaviour: hold the cash or inventory a delivery needs, or allow shorting where the strategy intends it. Target horizon: v5.0. Dependencies: FUT-015 (settlement-date accounting). Completion criteria: a declared margin model computes requirements for derivative and short positions, a shortfall produces a margin event and a declared liquidation policy acts on it, and both are recorded and replayable." + rationale: "Deferred work kept where it is tracked is work." + release: "v4.2.0" + implementation_status: "accepted as future work (v5.0, proposed by the FUT-013 milestone, pending the maintainer's decision): ROADMAP Future work FUT-017" + test_status: "n/a" + documentation_status: "ROADMAP.md (Future work); docs/audit/STRATEGY_CAPABILITY_MATRIX.md" + benchmark_status: "n/a" + api_impact: "additive" + architecture_impact: "local" + performance_impact: "neutral" + target_horizon: "v5.0" + verified: "v4.1.1 FUT-013 milestone" + candidate_outcome: "Accepted as future work, with the decision it needs named." # --- v4.0 historical inventory: generated by docs/audit/scripts/historical_inventory_v4.py - id: "HIS-001" category: "historical_inventory" diff --git a/docs/audit/RELEASE_CERTIFICATION.md b/docs/audit/RELEASE_CERTIFICATION.md index 3c3d590f..ad97eaff 100644 --- a/docs/audit/RELEASE_CERTIFICATION.md +++ b/docs/audit/RELEASE_CERTIFICATION.md @@ -7,9 +7,9 @@ Generated by `python docs/audit/scripts/certify_release.py` (ledger FEA-006) fro | Build | | | --- | --- | | Engine | alphalab 4.1.0 | -| Source digest | `188f514ee065496fcac686e611ca974e5e62f71129fa7d13fd9fb86f66221c84` | +| Source digest | `a9ec495689a4d52df59e95b1d691263bbcd3d678123ce583498ed5eee0b9ce7a` | | Time-zone database | 2024a | -| Certified on | Python 3.12.4 (CPython), macOS-26.6.2-arm64-arm-64bit | +| Certified on | Python 3.12.4 (CPython), macOS-27.0.1-arm64-arm-64bit | | Check | Claim | Result | | --- | --- | --- | @@ -105,10 +105,10 @@ Generated by `python docs/audit/scripts/certify_release.py` (ledger FEA-006) fro ### API-1 -- `manifest_digest`: `9f240d57df5666e02fc9f8c9d9e37bef2aaf1600bcac518543e83373c11d8090` +- `manifest_digest`: `074a8d0cde302752eb3ce92ad9699548fe46dd17331a9ebf674ee121068f5537` - `packages`: `44` - `documented_modules`: `12` -- `exports`: `2628` +- `exports`: `2667` - `shared_names`: `35` ## What this does not certify diff --git a/docs/audit/STRATEGY_CAPABILITY_MATRIX.md b/docs/audit/STRATEGY_CAPABILITY_MATRIX.md new file mode 100644 index 00000000..ca518702 --- /dev/null +++ b/docs/audit/STRATEGY_CAPABILITY_MATRIX.md @@ -0,0 +1,618 @@ +# AlphaLab Strategy Capability Matrix (v4.1.1 audit, v4.2.0 candidate) + +**Baseline:** `v4.1.0`, commit `33e61d3edbd041bcd125d5dea913c00cdc9da613` (tag +and `main` identical at the start of this work; working tree clean, no stash). +**Working branch:** `work/v4.1.1-future-readiness`, uncommitted. The audit's +findings were then implemented on the same branch (the continuation, then the +FUT-009 and FUT-013 milestones); every row below is as of the FUT-013 milestone, and where a +row changed, what it said at the audit is kept in a phrase ("was ..."). +**Companions:** [`STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md`](STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md) +(the strategy families), [`FUTURE_READINESS_V4_1_1.md`](FUTURE_READINESS_V4_1_1.md) +(what was done, measured and left), [`V4_HISTORICAL_INVENTORY.md`](V4_HISTORICAL_INVENTORY.md) +(every v2.0-v3.13 deferral, classified at v4.0). + +Every status below was read from source, a test or an executed example in this +session. Names and documents alone were not taken as evidence: where a document +and the code disagreed, the code decided and the disagreement is listed as a +defect. + +## How this was established + +* **Import graph.** Built from the AST of every module under `alphalab/` (not + grep): for each top-level package, the packages it imports and the packages + that import it, plus how many test and example files import it. The script is + reproduced in [`FUTURE_READINESS_V4_1_1.md`](FUTURE_READINESS_V4_1_1.md), + section 3. Results are in the table in section 1. +* **Runtime paths.** Read from the drivers (`backtesting/engine.py`, + `backtesting/replay.py`, `runtime/session.py`, `runtime/live.py`), the run + (`runtime/run.py`) and the step (`runtime/execution_pipeline.py`), and + confirmed by running a researcher-defined strategy through them + (`examples/72_compose_a_multi_asset_strategy.py`). +* **Evidence.** A test is cited by file (and test name where one decides it). + "Example gate" means CI runs the example under `python -W error` on every push + but no test asserts its output. + +## Status vocabulary + +| Status | Meaning here | +| --- | --- | +| **VERIFIED** | IMPLEMENTED AND VERIFIED: implemented and exercised end to end through the path named, by a test that asserts the outcome. | +| **NOT E2E** | IMPLEMENTED BUT NOT END-TO-END VERIFIED: implemented and unit-tested; no test drives it through the path it is meant to serve. | +| **PARTIAL** | PARTIALLY IMPLEMENTED: part of the capability exists; what is missing is named. | +| **ISOLATED** | ISOLATED OR NOT CONNECTED TO A REQUIRED PATH: a working library no driver or facade reaches; a researcher can call it from strategy code. | +| **MISSING** | Not present. | +| **EXTERNAL** | EXTERNAL INTEGRATION REQUIRED: the contract is AlphaLab's, the provider, venue or data is the host's. | +| **BOUNDARY** | INTENTIONALLY UNSUPPORTED WITH A JUSTIFIED BOUNDARY: kept out on purpose, the reason recorded (ledger or ADR named). | +| **DEFECT** | DEFECT OR DOCUMENTATION CLAIM MISMATCH: the code or a document says something false. Each one found here is fixed in this candidate unless stated. | + +"v4.1.1" against a status means this candidate changed it; the change and its +test are in [`FUTURE_READINESS_V4_1_1.md`](FUTURE_READINESS_V4_1_1.md). + +--- + +## 1. Packages: what imports what, and which paths reach them + +43 top-level packages and the `alphalab.api` module (44 entries). `in` counts the +other packages that import it; `tests` / `int` / `ex` count test files, of which +integration-or-regression, and example files that import it. "Spine" is the +execution path ADR-0009 and ADR-0030 name; "lifecycle" is the governance path +joined to it by `lifecycle.execution`. + +| Package | Lines | in | Imported by | tests / int / ex | Reached by | +| --- | ---: | ---: | --- | --- | --- | +| `allocation` | 3,378 | 2 | reinforcement_learning, runtime | 48 / 40 / 17 | spine | +| `alt_data` | 4,378 | 5 | api, backtesting, factor_library, research, runtime | 14 / 5 / 4 | spine (observations, since v3.12), research, api | +| `analytics` | 6,633 | 5 | api, backtesting, factor_library, portfolio_optimizer, runtime | 33 / 14 / 8 | spine (finalize), research | +| `api` | 1,656+ | 0 | -- (the facade) | 21 / 14 / 29 | the application surface | +| `backtesting` | 1,653+ | 2 | api, lifecycle | 52 / 45 / 16 | spine driver | +| `broker` | 4,318 | 3 | brokers, lifecycle, runtime | 46 / 30 / 10 | live path | +| `brokers` | 952 | 0 | -- | 6 / 5 / 1 | standalone (many-venue registry; ADR-0012) | +| `cloud_research` | 436 | 0 | -- | 2 / 1 / 0 | standalone | +| `cluster_scheduler` | 265 | 0 | -- | 1 / 0 / 0 | standalone | +| `common` | 4,211 | 44 | every package | 134 / 82 / 19 | the leaf | +| `conventions` | 1,346 | 9 | allocation, api, futures, instrument, lifecycle, macro, portfolio, portfolio_optimizer, runtime | 13 / 6 / 8 | spine (instrument economics) | +| `core` | 2,252 | 14 | 14 packages | 111 / 61 / 29 | spine (domain models) | +| `crypto` | 1,084 | 0 | -- | 5 / 3 / 1 | standalone | +| `data` | 6,267 | 8 | api, factor_library, lifecycle, market, marketdata, risk, runtime, scheduler | 65 / 37 / 27 | ingestion -> api -> spine | +| `deep_learning` | 1,568 | 1 | reinforcement_learning | 4 / 0 / 0 | standalone | +| `deployment_manager` | 544 | 1 | lifecycle | 6 / 4 / 0 | lifecycle | +| `distributed` | 1,005 | 2 | cloud_research, cluster_scheduler | 5 / 2 / 0 | standalone | +| `execution` | 6,834 | 4 | backtesting, broker, lifecycle, runtime | 87 / 68 / 24 | spine | +| `experiment_tracking` | 437 | 2 | lifecycle, research_assistant | 12 / 6 / 1 | lifecycle | +| `factor_library` | 5,309 | 2 | api, research | 30 / 14 / 15 | research (study path) | +| `feature_store` | 982 | 1 | ml | 7 / 2 / 1 | standalone | +| `futures` | 1,344 | 1 | api | 7 / 4 / 2 | api (contract lift) only | +| `instrument` | 2,436 | 4 | api, market, risk, runtime | 37 / 31 / 14 | spine (identity, economics) | +| `lifecycle` | 14,316 | 0 | -- | 62 / 38 / 22 | lifecycle path | +| `macro` | 953 | 0 | -- | 6 / 3 / 1 | standalone | +| `market` | 2,287 | 6 | api, backtesting, factor_library, futures, reinforcement_learning, runtime | 81 / 66 / 20 | spine | +| `marketdata` | 750 | 1 | market | 10 / 10 / 1 | spine (transports for streaming) | +| `ml` | 770 | 2 | cloud_research, deep_learning | 3 / 1 / 0 | standalone | +| `model_registry` | 1,721 | 1 | lifecycle | 23 / 16 / 3 | lifecycle | +| `oms` | 1,738 | 3 | backtesting, lifecycle, runtime | 41 / 35 / 2 | spine | +| `options` | 2,733 | 2 | api, data | 11 / 4 / 3 | api (contract lift); pricing standalone | +| `persistence` | 1,408 | 9 | 9 packages | 87 / 74 / 5 | spine (codec, run store) | +| `portfolio` | 6,914 | 7 | backtesting, crypto, futures, lifecycle, options, reinforcement_learning, runtime | 73 / 52 / 25 | spine | +| `portfolio_optimizer` | 7,185 | 0 | -- | 16 / 6 / 4 | standalone (called by strategy code) | +| `reinforcement_learning` | 767 | 0 | -- | 3 / 2 / 0 | standalone (drives the real pipeline) | +| `replay` | 592 | 1 | backtesting | 5 / 3 / 1 | spine (the replay cursor) | +| `reporting` | 552 | 0 | -- | 2 / 1 / 0 | standalone | +| `research` | 6,819 | 4 | api, cloud_research, lifecycle, research_assistant | 28 / 13 / 11 | research, lifecycle | +| `research_assistant` | 572 | 0 | -- | 3 / 2 / 1 | standalone | +| `risk` | 1,577 | 3 | lifecycle, reinforcement_learning, runtime | 25 / 17 / 19 | spine (pre-trade gate) | +| `runtime` | 12,190+ | 4 | api, backtesting, lifecycle, reinforcement_learning | 107 / 97 / 19 | spine | +| `scenario` | 1,218 | 0 | -- | 4 / 3 / 2 | standalone by design (imports only `common`) | +| `scheduler` | 1,077 | 0 | -- | 6 / 3 / 1 | standalone (timers reach the spine through `RunEngine.fire_timer`) | +| `strategy` | 4,830+ | 7 | allocation, api, backtesting, lifecycle, reinforcement_learning, research_assistant, runtime | 97 / 73 / 23 | spine | + +Counts are v4.1.0's (a `+` marks a package this candidate extends). The graph is +acyclic at the package level (`tests/regression/test_import_graph_stays_acyclic.py` +measures it) and stays so after this candidate: `alphalab.backtesting` and +`alphalab.runtime` already imported `alphalab.alt_data`. + +**Not "all 43 are integrated".** Twelve packages have no in-repository consumer +other than the facade (`brokers`, `cloud_research`, `cluster_scheduler`, +`crypto`, `lifecycle` -- which is a top-level composition --, `macro`, +`portfolio_optimizer`, `reinforcement_learning`, `reporting`, +`research_assistant`, `scenario`, `scheduler`), and four more are reached only +by another standalone package (`deep_learning`, `distributed`, `feature_store`, +`ml`). That is mostly deliberate (ADR-0009: standalone engines are libraries a +strategy or host calls), and where it leaves a capability unconnected that a +researcher would expect to be connected, the entry below says so. + +--- + +## 2. Capabilities + +Each entry answers the twelve questions in order: **(1)** historical intent and +release; **(2)** source; **(3)** canonical implementation and authoritative +types; **(4)** public API; **(5)** what it depends on; **(6)** what consumes it; +**(7)** which paths it is on (R research, B backtest, Re replay, P paper, L live); +**(8)** status; **(9)** evidence; **(10)** risks; **(11)** fixes and their +dependencies; **(12)** acceptance criteria for "ready". + +### 2.1 The strategy contract + +| | | +| --- | --- | +| (1) | v1.x strategy runtime design; context populated v2.10 (ADR-0026); `on_fill`/`on_order`/`on_timer` and `on_start` delivered v3.11 (EXE-005, OFE-014); `on_slice` v3.11 (EXE-004); `on_observation` v3.12 (OFE-009); class registry v2.17 (ADR-0035). | +| (2) | `alphalab/strategy/protocol.py`, `context.py`, `dispatcher.py`, `events.py`, `registry.py`, `state.py`, `supervisor.py`; context views in `alphalab/runtime/context_views.py`. | +| (3) | `StrategyProtocol` (ten hooks) + `SliceStrategyProtocol` + `ObservationStrategyProtocol` + `StrategyStateProtocol`; `BaseStrategy`; `StrategyContext` (portfolio, market, history bounded at the event, universe, risk view, orders, clock, logger, config); `Intent` with `IntentKind.DELTA` / `TARGET_QUANTITY` / `TARGET_WEIGHT` and `OrderTerms`. | +| (4) | `alphalab.strategy` (96 names): `BaseStrategy`, `Intent`, `IntentKind`, `StrategyContext`, `start_strategy`, `context_factory`, `StrategyClassRegistry`, `StrategyDefinition`, `runtime_for`. | +| (5) | `alphalab.common` only (pinned by `tests/regression/test_v37_invariants.py`). | +| (6) | runtime, allocation, backtesting, api, lifecycle, reinforcement_learning, research_assistant. | +| (7) | R (adaptive research replay), B, Re, P, L -- one dispatcher on every path. | +| (8) | **VERIFIED**, with one **DEFECT fixed in v4.1.1**: `context.market.bar(asset_id)` returned `None` for every asset since v2.10 -- `MarketView.bar` looked up the bare asset id in an index keyed `"_"`, and no test read a bar through the context. | +| (9) | `tests/integration/test_strategy_building_end_to_end.py` (example 70); `test_slices.py`; `test_external_information.py`; `test_strategy_feedback.py`; `tests/regression/test_strategy_context_visibility.py`, `test_strategy_context_history_and_universe.py`; v4.1.1: `tests/regression/test_strategy_context_reads_bars.py` (fails on v4.1.0's lookup), `tests/integration/test_multi_asset_composition.py`. | +| (10) | No finite list of strategy types exists anywhere: the registry maps an identity to *any* factory returning a `StrategyProtocol` (`registry.py`), which is what makes the universe open. Hooks run in-process with the caller's code: hosting untrusted strategies is the host's isolation problem (BOUNDARY, `docs/INTEGRATION.md`). `MarketView.bar` is linear in the latest-bar index (same order as `prices`). | +| (11) | `MarketView.bar` fixed (v4.1.1). | +| (12) | A researcher's class, unknown to AlphaLab, trades several instruments from several input kinds through the public API and is reproduced -- met by example 72 and its test. | + +### 2.2 Strategy state, capture and restore + +| | | +| --- | --- | +| (1) | ADR-0025 (v2.10); run snapshot envelope ADR-0023; one `RunState` ADR-0030. | +| (2) | `alphalab/strategy/protocol.py:StrategyStateProtocol`, `alphalab/runtime/snapshot.py`, `run_snapshot.py`. | +| (3) | `capture_state` / `restore_state` / `strategy_state_version`, carried in the pipeline snapshot; `RunObjects` supplies live objects back, checked against the recorded configuration (PER-008). | +| (4) | `alphalab.runtime.run_snapshot.capture`, `from_primitives`, `restore`; `BacktestEngine.resume`. | +| (5) | persistence codec. | +| (6) | every driver; FileRunStateStore. | +| (7) | B, Re (pause/resume, v4.1), P, L. | +| (8) | **VERIFIED**. A strategy configuration's `Decimal`/tuple reads back as JSON reads it: **LIMITATION** LIM-004 / future FUT-002. | +| (9) | `tests/regression/test_durable_run_continuation.py`, `test_replay_pause_and_resume.py` (135), `test_restore_requires_the_captured_configuration.py`; v4.1.1: example 72 stopped at **every** record boundary, stored, restored with a registry-built instance and continued to the identical `result_id` (`test_multi_asset_composition.py::test_a_backtest_stored_at_any_record_and_continued_is_the_same_result`). | +| (10) | A strategy that does not declare its state continues only conditionally (documented in ADR-0025). | +| (11) | none. | +| (12) | met. | + +### 2.3 Drivers: backtest, replay, paper, live + +| | | +| --- | --- | +| (1) | Backtest v2.2 (ADR-0010); replay through the pipeline v2.2, resumable v4.1 (FUT-001); paper/live drivers over one run v2.14 (ADR-0030); `LiveSession` v2.16 (ADR-0033). | +| (2) | `alphalab/backtesting/engine.py`, `replay.py`, `replay_snapshot.py`; `alphalab/runtime/session.py`, `live.py`, `run.py`. | +| (3) | `RunEngine` owns the run; drivers hold no state. | +| (4) | `BacktestEngine.run`, `ReplayBacktest.run/resume`, `TradingSession.run`, `LiveSession.*`, `alphalab.api.backtest/replay/resume_replay`. | +| (5) | runtime, market, replay cursor, broker (live). | +| (6) | api, lifecycle (certification, evidence). | +| (7) | B, Re, P, L. | +| (8) | **VERIFIED** for market records and for every timed input. Was, at the audit: `ReplayBacktest.run` took no observations, the facade took none, `LiveSession` could not deliver one, a durable paused replay with deliveries outstanding was refused (FUT-003), and no historical driver took rates, funding, cash flows, timers or settlements (FUT-004). Now one `RunSchedule` (`alphalab.backtesting.schedule`) carries observations, rates, funding, cash flows, per-unit distributions, funding rates, splits, timers and contract settlements, applied by `BacktestEngine.run`, `ReplayBacktest.run` and the facade through one `ScheduleCursor`; a paused replay's payload (replay schema 2) records the schedule's digest and resumes durably; `LiveSession.deliver_observation` delivers and routes. Inputs dated after the data are not applied. `TradingSession.run` (paper over a source) takes no schedule -- a host applies inputs with the `RunEngine` calls between records. | +| (9) | `tests/integration/test_backtest_replay_parity.py`, `test_paper_and_live_sessions.py`, `test_live_session.py`, `test_streaming_session.py`; v4.1.1: `tests/regression/test_heterogeneous_inputs_on_every_driver.py` (82, a payload round trip at every boundary among them), `tests/integration/test_scheduled_inputs.py` (34). | +| (10) | A schedule is fixed before the run; a host whose inputs arrive during a run uses `LiveSession` or the `RunEngine` calls. | +| (11) | Done (OFE-027, FUT-003, FUT-004). | +| (12) | Every driver delivers the same information at the same step; a paused replay with information outstanding round-trips durably. | + +### 2.4 Allocation, multi-strategy netting and strategy attribution + +| | | +| --- | --- | +| (1) | ADR-0015 (v2.6); target intents v3.11 (FEA-001); strategy ceilings v3.12 (OFE-003); capital allocation v3.8 (ADR-0043). | +| (2) | `alphalab/allocation/` (engine, budget, sizing, capital, snapshot). | +| (3) | `AllocationEngine`, `CapitalBudget`, sizing models, reservation and contribution ledgers. | +| (4) | `alphalab.allocation` (60 names). | +| (5) | common, core, conventions, strategy, persistence. | +| (6) | runtime, reinforcement_learning. | +| (7) | B, Re, P, L. | +| (8) | **VERIFIED**. | +| (9) | `tests/integration/test_target_intents.py`, `test_strategy_ceilings.py`, `test_v39_capabilities.py::test_two_strategies_net_into_one_parent_that_keeps_both`, `test_v38_capabilities.py::test_strategy_pnl_attribution_is_each_run_s_own_and_sums_to_the_book`. | +| (10) | Allocation visibility in the strategy context is withheld on purpose (BDY-002). | +| (11) | none. | +| (12) | met. | + +### 2.5 Pre-trade risk + +| | | +| --- | --- | +| (1) | v1 risk engine; defects KD-001-003 fixed v3.10; classification-bucket limits v3.12 (OFE-001). | +| (2) | `alphalab/risk/`. | +| (3) | `RiskEngine`, `RiskLimits` (order size, position, exposure, leverage, margin, daily loss, drawdown), `RiskDecision` with `violations`. | +| (4) | `alphalab.risk` (52 names). | +| (5) | common, core, data, instrument. | +| (6) | runtime, lifecycle, reinforcement_learning. | +| (7) | B, Re, P, L. | +| (8) | **VERIFIED**. Risk *measurement* of a held book is `alphalab.analytics`, not this (pinned distinct). | +| (9) | `tests/integration/test_classification_limits.py`; `test_backtest_pipeline.py::test_a_request_risk_refuses_never_reaches_the_oms`; v4.1.1: example 72's order-size refusals, each in `result.state.risk.history` with its violation and never filled. | +| (10) | The position check adds notional exposure to order quantity (recorded in ADR-0041; example comments warn). | +| (11) | none. | +| (12) | met. | + +### 2.6 OMS and order lifecycle + +| | | +| --- | --- | +| (1) | v1 OMS; persistent maps v2.x; resting orders and time in force v3.11 (EXE-003). | +| (2) | `alphalab/oms/`. | +| (3) | `OMSEngine`, `Order`, `OrderStatus`, `OrderBook`, snapshot. | +| (4) | `alphalab.oms`. | +| (5) | common, core, persistence. | +| (6) | runtime, backtesting, lifecycle. | +| (7) | B, Re, P, L. | +| (8) | **VERIFIED**. | +| (9) | `tests/integration/test_order_terms.py`, `test_day_order_sessions.py`, `tests/regression/test_v39_*`. | +| (10) | Per-order memory grows with orders (LIMITATION PRF-011). | +| (11) | none. | +| (12) | met. | + +### 2.7 Execution simulation, costs, liquidity and capacity + +| | | +| --- | --- | +| (1) | v1 simulator; v3.3 cost model, capacity, liquidity-capped fills (ADR-0038); fill timing v3.10 (EXE-002). | +| (2) | `alphalab/execution/simulator.py`, `costs.py`, `policy.py`, `capacity.py`, `slippage.py`, `commission.py`. | +| (3) | `ExecutionSimulator`, `ExecutionCostModel` (spread, slippage, impact, commission, fee, tax), `FillTiming`, `LiquidityCappedFill`, `ExecutionAssumptions` (each optimistic default named on the result). | +| (4) | `alphalab.execution` (141 names). | +| (5) | common, core. | +| (6) | runtime, broker, backtesting, lifecycle. | +| (7) | B, Re, P (simulated venue). | +| (8) | **VERIFIED**. No queue-position or fill-probability model: **MISSING** (section 2.18). | +| (9) | `tests/integration/test_v33_capabilities.py`, `tests/regression/test_v33_invariants.py`; v4.1.1: example 72 runs with `NEXT_EVENT` timing, a 10% participation cap and a six-role cost model, and `execution_assumptions.optimistic == ()`. | +| (10) | Defaults are optimistic and are *stated*, not hidden (`result.execution_assumptions`). | +| (11) | none. | +| (12) | met for bar- and quote-driven simulation. | + +### 2.8 Execution algorithms, routing and execution quality + +| | | +| --- | --- | +| (1) | v3.9 universal execution contract (ADR-0044). | +| (2) | `alphalab/execution/algorithms.py`, `routing.py`, `quality.py`; `alphalab/runtime/live.py` (`hold`, `route_child`); since the FUT-009 milestone `alphalab/backtesting/algorithmic.py` and `ExecutionPipeline.work_child`. | +| (3) | TWAP / VWAP / participation / slicing / iceberg, `ChildOrder`, `AlgorithmState`, `RouteDecision`, implementation shortfall; on the historical path `ExecutionPlan`, `AlgorithmBook`, `ExecutionRouting.ALGORITHMIC`. | +| (4) | `alphalab.execution`; `LiveSession.hold/route_child/cancel/amend`; `BacktestEngine.run(..., execution=)`, `ReplayBacktest.run(..., execution=)`, `alphalab.api.backtest/replay(..., execution=)`. | +| (5) | common, core; the historical path also runtime (the pipeline's fill policy, simulator, OMS, portfolio and allocation). | +| (6) | runtime.live; backtesting (both historical drivers). | +| (7) | B, Re, L (and paper through `PaperBroker`). Paper over a source (`TradingSession`) routes to its broker, not to an algorithm book. | +| (8) | **VERIFIED** on the live path and, since the FUT-009 milestone, on both historical drivers (was **PARTIAL**: a backtest filled every order whole). Each parent is one OMS order, held under `ALGORITHMIC` routing -- risk judged it whole, allocation reserved it -- and worked in children at its asset's events; each child is filled by the run's own fill policy and cost model (`work_child`) and settled on the parent through the canonical report path, so the OMS, portfolio, reservation and attributed trade records see one order. TWAP, VWAP, slicing, iceberg and participation (`LEAVE_UNFILLED`) are evidenced; participation with `COMPLETE_AT_END` is refused by the plan. | +| (9) | `tests/integration/test_algorithms_on_the_historical_path.py` (57: quantities reconcile, nothing overfills, partial fills and catch-up, no liquidity, rejection, a venue declaration refusing a child, a split cancelling the parent, incomplete at the data's end, both fill timings, no look-ahead in price or volume, costs per fill, attribution of a netted parent, replay parity, a replay paused at every boundary resumed from its payload, the same children the live route sends a `PaperBroker`); `tests/integration/test_execution_algorithm_example.py`; `examples/74_evaluate_an_execution_algorithm.py`; `test_v39_capabilities.py` for the live path. Eight mutants of the guards, each caught. | +| (10) | Passive (limit) children fill by the simulator's resting rule, with no queue model (FUT-011). A child's fill reaches its strategy through the portfolio, not `on_fill`, as a venue fill does live. A `PaperBroker` fills a market child at the parent's reference price, so the backtest equals the paper route child for child, not price for price (ADR-0051 decision 13). | +| (11) | Done (FUT-009). | +| (12) | Met: an algorithm's children are evaluated by `BacktestEngine` and `ReplayBacktest` with the run's own fills, child for child the ones the live route sends. | + +### 2.9 Portfolio accounting and instrument economics (multi-asset) + +| | | +| --- | --- | +| (1) | v2.1 mark-to-market; ADR-0028 currency authority (v2.12); multi-currency settlement ADR-0035 (v2.17); instrument economics on the canonical path v3.11 (ACC-005/006/007). | +| (2) | `alphalab/portfolio/engine.py`, `position.py`, `contracts.py`, `corporate_actions.py`; `alphalab/instrument/`; `alphalab/conventions/economics.py`. | +| (3) | `PortfolioEngine` (one book of record), `InstrumentRecord` + `InstrumentEconomics` (multiplier, settlement model, lot, minimum notional, negative prices), `CashFlow`, `Split`. | +| (4) | `alphalab.portfolio`, `alphalab.instrument`, `alphalab.conventions`. | +| (5) | common, core, conventions, persistence. | +| (6) | runtime and everything on the spine. | +| (7) | B, Re, P, L. | +| (8) | **VERIFIED** for cash equities, futures (variation margin), options (premium x multiplier), perpetuals (funding as a cash flow), splits, dividends, fees; a future or option with no declared economics is refused before an order exists. | +| (9) | `tests/integration/test_instrument_economics.py`; `test_mark_to_market_pipeline.py`; `tests/regression/test_close_fill_cash_accounting.py`; v4.1.1: example 72's index future held in whole contracts, notional never paid, accounting identity exact. | +| (10) | Borrow availability is not modelled -- a borrow *fee* is a stated `CashFlow` (BOUNDARY: the host's data). | +| (11) | none. | +| (12) | met. | + +### 2.10 Instrument identity, calendars and conventions + +| | | +| --- | --- | +| (1) | ADR-0016 (v2.7), classification ADR-0027 (v2.11), conventions and exchange calendars v3.4 (ADR-0039), calendars inside simulation v3.12 (EXE-010), dated aliases v3.11 (DAT-004). | +| (2) | `alphalab/instrument/`, `alphalab/data/calendar.py`, `alphalab/runtime/calendars.py`, `alphalab/conventions/`. | +| (3) | derived `asset_id`, `InstrumentRegistry`, `MarketCalendar`, `VenueCalendars`, `MarketConvention`, `SettlementRule`, `TickSchedule`, `LotSpecification`. | +| (4) | as named. | +| (5) | common, core, conventions. | +| (6) | market normalization, runtime, api. | +| (7) | B, Re, P, L. | +| (8) | **VERIFIED**. AlphaLab ships no holiday or reference data: **EXTERNAL** (BDY-013). | +| (9) | `tests/integration/test_day_order_sessions.py`, `tests/regression/test_instrument_identity_reaches_a_fill.py`; example 72 declares two venues' instants; since FUT-013 `tests/integration/test_lifecycle_conventions.py` -- four synthetic profiles (a zone without daylight saving, two that change on different dates, a Sunday-to-Thursday market with a lunch break and a holiday) through `session_close` and `settles_at` and the same option lifecycle, no market named in the engine. | +| (10) | Holidays are the caller's declaration; a missing one is an extra session. A lifecycle date the market does not trade on is refused rather than moved: which way it moves is the contract's convention. Trade-date settlement lag is computed, not booked (FUT-015). | +| (11) | FUT-015 (settlement-date accounting). | +| (12) | met. | + +### 2.11 FX rates, conversion and multi-currency runs + +| | | +| --- | --- | +| (1) | ADR-0019/0020 (v2.8), conversion with stated rates ADR-0033 (v2.16), the feed boundary ADR-0035 (v2.17), look-ahead refusal v3.4. | +| (2) | `alphalab/portfolio/fx.py`, `fx_feed.py`, `fx_research.py`; `ExecutionPipeline.fund/convert_cash`. | +| (3) | `FxRate(as_of, source)`, `FxRates` (one rate per pair; `FutureDatedRateError`, `StaleRateError`), `FxFeed`. | +| (4) | `alphalab.portfolio`, `alphalab.portfolio.fx_feed`. | +| (5) | common. | +| (6) | runtime (every step takes `rates`). | +| (7) | B, Re (rates and `Funding` on the run's schedule), P, L (stepped by the host). | +| (8) | **VERIFIED** (was **PARTIAL**: step-level only, FUT-004). A book settling two currencies is funded and traded through `BacktestEngine.run` and `ReplayBacktest.run`, each step valued at the rate in force at it; funding a currency with no rate yet quoted is refused. No FX data is shipped (EXTERNAL). | +| (9) | `tests/integration/test_scheduled_inputs.py::test_a_two_currency_book_is_funded_and_traded_through_the_driver`, `::test_each_step_is_valued_at_the_rate_in_force_at_it_and_never_a_later_one`, `::test_the_driver_equals_a_loop_that_states_every_rate_by_hand`; `test_v217_capabilities.py`; `examples/14_multi_currency_settlement.py`. | +| (10) | Rates are the host's; a stale rate is refused only when `rate_max_age_seconds` is declared on the schedule. | +| (11) | Done (FUT-004). | +| (12) | A backtest of a book settling two currencies, driven by `BacktestEngine.run`, applying each rate at its `as_of`, equal to the hand loop. | + +### 2.12 Data ingestion, validation, provenance and dataset identity + +| | | +| --- | --- | +| (1) | Universal data engine v3.1 (ADR-0036); rows identified by content v3.10 (KD-004). | +| (2) | `alphalab/data/` (source, csv, schema, time, validation, cleaning, quality, provenance, dataset, ingestion). | +| (3) | `Dataset` with `DatasetProvenance` (source bytes digest, schema, policy, transformations -> derived `dataset_version`). | +| (4) | `alphalab.api.ingest_csv`, `ingest_rows`, `inspect_csv`, `validate_dataset`, `clean_dataset`, `select`, `to_market_dataset`. | +| (5) | common, options (one enum). | +| (6) | api, factor_library, lifecycle, market, risk, runtime. | +| (7) | R, B, Re (P/L read live sources instead). | +| (8) | **VERIFIED**. One dataset is one series kind (`IngestionRequest.asset_class`) from one source. | +| (9) | `tests/integration/test_v32_capabilities.py`, `tests/unit/test_api.py`, `examples/15_data_ingestion.py`. | +| (10) | -- | +| (11) | none. | +| (12) | met. | + +### 2.13 Several datasets in one run (multiple providers, multiple asset classes) + +| | | +| --- | --- | +| (1) | Not addressed before v4.1.1. | +| (2) | v4.1.1: `alphalab/api.py` (`CombinedDataset`, `CombinedProvenance`, `combine_datasets`, `combined_market_dataset`). | +| (3) | `CombinedDataset` satisfies the manifest's `VersionedDataset` protocol; identity `combined-`. | +| (4) | `alphalab.api`. | +| (5) | data, market normalization, backtesting dataset. | +| (6) | any driver (a `MarketDataset`); `lifecycle.manifest_for_run`. | +| (7) | B, Re. | +| (8) | Was **MISSING**: a run could name one dataset, so a two-venue or two-provider run carried an id its author typed and no manifest could check it. **VERIFIED (v4.1.1)**. | +| (9) | `tests/unit/test_api_combined_datasets.py` (8); `tests/integration/test_multi_asset_composition.py::test_the_manifest_names_the_combined_datasets_and_checks_them`. | +| (10) | Records are not aligned or forward-filled (by design); two parts' prices for one instrument at one instant are refused. The manifest records each part (`ReproducibilityManifest.dataset_parts`), checked to derive the combined identity, and `external_requirements` lists them one by one, in order. | +| (11) | Done (FUT-005). | +| (12) | met. | + +### 2.14 Point-in-time external information (alternative data, events, fundamentals) + +| | | +| --- | --- | +| (1) | Typed v1 categories (PR-040); the v3.7 point-in-time foundation (ADR-0042); delivery on the execution path v3.12 (OFE-009); streams OFE-011. | +| (2) | `alphalab/alt_data/` (observation, information, fundamentals, observation_set, source, streaming, sessions, provenance; v1: sentiment, news, satellite, shipping, esg, credit_card); `alphalab/common/point_in_time.py`. | +| (3) | `PointInTimeStamp` (observed, effective, available, ingested; basis; rule), `VisibilityRule` (PUBLICATION, INGESTION), `ExternalObservation` (open category/metric vocabulary, `ReferencePeriod`, `revision`), `InformationEvent`, `FundamentalObservation`, versioned `ObservationSet`, `DeliverySchedule`. | +| (4) | `alphalab.api.ingest_observations`, `ingest_events`, `ingest_fundamentals`, `observation_source`, `AvailabilityFromColumn` / `AfterLag` / `AtNextOpen` / `NotDeclared`; `alphalab.alt_data.delivery_schedule`. | +| (5) | common only. | +| (6) | api, research, factor_library, backtesting, runtime. | +| (7) | R; B (v3.12); Re, api facade, L wrapper (v4.1.1). | +| (8) | **VERIFIED**. A row whose availability is not stated is `UNKNOWN`, counted and never delivered; an exact repeat or unreadable row is rejected by line; a revision published ahead of the figure it revises is refused. | +| (9) | `tests/integration/test_external_information.py`, `test_v37_capabilities.py`; `tests/unit/alt_data/`; v4.1.1: `test_multi_asset_composition.py` (late post, unknown availability, duplicate, unreadable, out-of-order file, and a leaking control). | +| (10) | The ingestion instant is never substituted for availability (a test proves a leaky schedule is caught). Licensing of any dataset is the host's (EXTERNAL). | +| (11) | none. | +| (12) | met. | + +### 2.15 Social-media and news sentiment + +| | | +| --- | --- | +| (1) | PR-040 typed `NewsSentiment`/aggregated sentiment; v3.7 generic observations. | +| (2) | `alphalab/alt_data/sentiment.py`, `news.py` (typed v1); `ExternalObservation(category="sentiment")`. | +| (3) | As 2.14. AlphaLab computes no sentiment from text: scores are inputs. | +| (4) | As 2.14. | +| (5)-(6) | As 2.14. | +| (7) | R, B, Re, L. | +| (8) | **VERIFIED** as an input stream (v4.1.1 acceptance). Text-to-score models and any social API are **EXTERNAL** / BOUNDARY (no vendor SDK or LLM dependency in the engine). The typed v1 records (`NewsSentiment`, ...) are read through `known_as_of` and are **ISOLATED** from the delivery path (they are not `SetRecord`s); the generic form is the one that reaches a strategy. | +| (9) | `tests/integration/test_multi_asset_composition.py`; `examples/51_alternative_data_provenance.py`. | +| (10) | Entity mapping from a post's ticker to an instrument is the caller's key (`subject`), by design (observations are not `asset_id`-keyed). | +| (11) | none required. | +| (12) | met. | + +### 2.16 Macroeconomic data: releases, vintages, revisions + +| | | +| --- | --- | +| (1) | PR-039 Macro Engine (indicators with point-in-time correctness, central-bank events, yield curves, GDP; v3.4 bond analytics). | +| (2) | `alphalab/macro/` (standalone); on the information path through `ExternalObservation` with `ReferencePeriod` and `revision`. | +| (3) | For a strategy: `ExternalObservation` -- the period measured (`ReferencePeriod`), the release instant (`available_at`) and the vintage (`revision`) are three fields, never one. `alphalab.macro.indicator` keeps its own point-in-time model for analysis. | +| (4) | `alphalab.api.ingest_observations` with `ObservationColumns(period=..., revision=...)`; `alphalab.macro`. | +| (5) | macro: common, conventions. | +| (6) | macro: `alphalab.api.observations_from_indicator`, `bond_schedule`. | +| (7) | Information path: R, B, Re, L; `alphalab.macro` series reach it through the lift. | +| (8) | Releases/vintages to a strategy: **VERIFIED (v4.1.1 acceptance)**. `alphalab.macro`: **VERIFIED** on the path (was **ISOLATED**, FUT-008) -- `observations_from_indicator` lifts its releases into an `ObservationSet`: observed at the reference period, known at the release date, the vintage numbered by release order; a series whose revision flags contradict it is refused. Yield curves and other derived analytics stay R libraries. | +| (9) | `tests/integration/test_multi_asset_composition.py::test_every_decision_used_the_cpi_vintage_published_by_then`; `tests/unit/test_api_macro_lift.py` (8, against `macro.known_as_of`); `tests/unit/macro/`. | +| (10) | The lift is one-way and per indicator. | +| (11) | Done (FUT-008). | +| (12) | A macro-engine series can be scheduled for delivery without re-ingesting it. | + +### 2.17 Features, factors and signal research + +| | | +| --- | --- | +| (1) | Feature Store PR-034; Factor Library PR-035; v3.2 study path (ADR-0037). | +| (2) | `alphalab/factor_library/`, `alphalab/feature_store/`, `alphalab/research/`. | +| (3) | `FeatureDefinition` -> `FeaturePanel`; style factors; IC, decay, turnover, neutralization, carry, value, quality, volatility, liquidity; `ResearchStudy`/`StudyResult`. | +| (4) | `alphalab.api.run_study`, `study_panels`, `observe`; `alphalab.factor_library`, `alphalab.research`. | +| (5) | common, data, alt_data, analytics, market. | +| (6) | api, research, lifecycle (study manifests). | +| (7) | R. Features reach a *trading* strategy only as code the strategy runs, or as values a study computed and the strategy reads -- there is no feature-serving component on the execution path. | +| (8) | Research: **VERIFIED**. Feature Store: **ISOLATED** (consumed only by `ml`). | +| (9) | `tests/integration/test_v32_capabilities.py`, `test_v37_capabilities.py::test_knowledge_frames_feed_the_feature_engine_with_lineage`; examples 17-24. | +| (10) | Was: nothing checked a study's features against a strategy's (FUT-006). Now a strategy evaluates the study's own `FeatureDefinition`s with `compute_feature` over the history it has been shown and equals the study panel at every instant (`examples/73_train_and_trade_a_model.py`; `tests/integration/test_model_on_the_execution_path.py::test_the_strategy_computes_the_studys_features_exactly`, 111 instants). There is no incremental evaluator: each evaluation recomputes its window (ADR-0051 known limitation). | +| (11) | Done as evidence (FUT-006). | +| (12) | The same `FeatureDefinition` gives the same value in a study panel and in a strategy at the same instant. | + +### 2.18 Validation methodology + +| | | +| --- | --- | +| (1) | v3.2 (ADR-0037): walk-forward, time-series CV with purging and embargo, robustness, overfitting diagnostics; multiple testing and Sharpe inference v3.12. | +| (2) | `alphalab/research/` (walk_forward, walk_forward_optimization, time_series_cv, purging, splits, overfitting, multiple_testing, sharpe_inference, bootstrap, montecarlo, sensitivity, perturbation, bias, regime, regimes, event_study, stress, capacity). | +| (3) | As named; deflated Sharpe, probabilistic Sharpe, Holm/Bonferroni, survivorship checks in `bias.py`. | +| (4) | `alphalab.research` (139 names). | +| (5) | common, analytics, alt_data. | +| (6) | api, lifecycle, research_assistant, cloud_research. | +| (7) | R. | +| (8) | **VERIFIED** on research payloads. | +| (9) | examples 20-23; `tests/unit/research/`; `tests/integration/test_v32_capabilities.py`. | +| (10) | Walk-forward over a *backtest* (re-running the execution path per fold) is user code; `walk_forward_optimization` evaluates a supplied function. | +| (11) | none required. | +| (12) | met. | + +### 2.19 Machine learning, deep learning, reinforcement learning, adaptive strategies + +| | | +| --- | --- | +| (1) | PR-041 ML, PR-042 DL (backprop verified; LSTM/attention gradients v3.12, SCF-004), PR-043 RL; adaptive strategies v3.7 (ADR-0042). | +| (2) | `alphalab/ml/`, `alphalab/deep_learning/`, `alphalab/reinforcement_learning/`, `alphalab/strategy/adaptive*.py`. | +| (3) | linear/logistic regression, k-fold and walk-forward splits; dense/conv1d/LSTM/attention; tabular Q-learning, REINFORCE, a trading environment over the real pipeline; `AdaptiveStrategy` with an immutable learned state and hash-chained lineage. | +| (4) | `alphalab.ml`, `alphalab.deep_learning`, `alphalab.reinforcement_learning`, `alphalab.strategy`. | +| (5) | ml: common, feature_store; dl: common, ml; rl: runtime, strategy, risk, allocation, portfolio, market, deep_learning. | +| (6) | ml: cloud_research, deep_learning; dl: rl; rl: nobody. | +| (7) | Adaptive: R, B, Re, P, L. ML: R, B (example 73). DL: R. RL: its own loop over the pipeline step. | +| (8) | Adaptive: **VERIFIED**. ML: **VERIFIED** (was **NOT E2E**, FUT-007) -- example 73 fits a linear model only on samples whose label was known before a boundary, declares it as the strategy's parameters, trades it after the boundary, restores it exactly from a stored run, and reproduces it under two hash seeds; a counterfactual on every price after the boundary changes neither the model nor an earlier decision. DL: **NOT E2E** -- a network is held the same way, but no test trades one. RL: **VERIFIED** for its environment; a **DEFECT fixed in v4.1.1**: its package and module docstrings still described a `PortfolioEngine.apply_fill` defect as unfixed, fixed in v2.0.0 (CHANGELOG D1) and asserted correct by its own test. | +| (9) | `tests/integration/test_model_on_the_execution_path.py` (12); `tests/integration/test_v37_capabilities.py::test_backtest_and_replay_agree_for_an_adaptive_strategy`; `tests/unit/ml/`, `tests/unit/deep_learning/`, `tests/unit/reinforcement_learning/test_reinforcement_learning.py::test_environment_round_trip_realizes_correct_pnl`. | +| (10) | Pure-Python linear algebra (no numpy, `dependencies = []`): models are small and slow by design; a GPU or large-model workflow is the host's (BOUNDARY OFE-026). | +| (11) | Done for ML (FUT-007). | +| (12) | Met for a linear model: fitted only on data before a boundary, it drives orders after it, its parameters captured and restored exactly, the run reproduced. | + +### 2.20 Portfolio construction, risk budgets, multi-strategy books + +| | | +| --- | --- | +| (1) | ADR-0005 v1 optimizers; v3.8 `construct` and Black-Litterman, risk budgets, multi-strategy books, capital allocation (ADR-0043). | +| (2) | `alphalab/portfolio_optimizer/`, `alphalab/analytics/risk_budget.py`, `cross_strategy.py`, `alphalab/portfolio/multi_strategy.py`, `alphalab/allocation/capital.py`. | +| (3) | `construct` (min variance, mean-variance, max diversification, risk parity, robust MV) with certified statuses; `black_litterman`. | +| (4) | `alphalab.portfolio_optimizer`, `alphalab.analytics`, `alphalab.portfolio`, `alphalab.allocation`. | +| (5) | analytics, conventions, common. | +| (6) | portfolio_optimizer: nobody (standalone). | +| (7) | R; reaches B/L when a strategy calls it and emits `TARGET_WEIGHT`/`TARGET_QUANTITY` intents. | +| (8) | **VERIFIED** as research and as a strategy-called library (`examples/10_complete_pipeline.py`, example gate; `test_v38_capabilities.py`). **ISOLATED** from the drivers by design. | +| (9) | `tests/integration/test_v38_capabilities.py` (risk parity across strategies, factor-neutral sector-capped rebalance, Black-Litterman, multi-account allocation). | +| (10) | Dense solver is cubic (LIMITATION PRF-005/013, measured). | +| (11) | none required. | +| (12) | met. | + +### 2.21 Options, futures, crypto, fixed income + +| | | +| --- | --- | +| (1) | PR-036/037/038 engines; v3.4 IV inversion, chains, rolls, curves, funding; American options v3.13. | +| (2) | `alphalab/options/`, `alphalab/futures/`, `alphalab/crypto/`, `alphalab/macro/bond.py`. | +| (3) | Black-Scholes, Greeks, IV, surfaces, binomial American; contract chains, roll schedules, continuous contracts, calendar spreads; perpetual funding; fixed-rate bond cash flows, yield, duration, convexity. | +| (4) | as named; `alphalab.api.future_contract_from_spec`, `option_contract_from_spec`. | +| (5) | portfolio, conventions, common. | +| (6) | api (contract lift, `bond_schedule`), data (one enum), runtime (`last_trade_at`, settlement, exercise, assignment, expiry, delivery). | +| (7) | Booking, expiry, settlement, funding and coupons on the spine (B, Re; P, L stepped by the host); pricing, rolls and curves are R libraries. | +| (8) | Options and futures: **VERIFIED** as booking, analytics and expiry (was **MISSING**: a contract held past expiry stayed marked at its last price, FUT-012). `InstrumentRecord.last_trade_at` (instrument schema 4): records after it are skipped, requests refused before an order exists, and a `ContractSettlement` closes every position at the final price -- a future's last variation, an option exercised or assigned at intrinsic value, or expiring worthless -- cancelling working orders and attributing the result; a run that ends holding an expired contract is refused. A roll is the strategy's trade. Exercise, assignment and delivery: **VERIFIED** since the FUT-013 milestone (was **MISSING**). `InstrumentRecord.terms` declares `OptionTerms` (American or European with its window, cash or physical, one `Deliverable` in any quantity, an automatic-exercise threshold or none) or `FutureTerms` (cash, or delivery); `Exercise`, `Assignment` and `OptionExpiry` on the schedule move cash, contracts and the deliverable through the portfolio's fill authority and each strategy's share -- a cash payoff `max(S x units x m_deliverable / m - K, 0)` per unit of price, a physical delivery against `strike x multiplier` marked at the market's price, a physical future delivered at its final price; an option on a future delivers a futures position. Assignment before expiry is an input; at expiry the declared rule assigns. `AssetType.INDEX` is the reference a cash option settles against. Baskets, cash in lieu, quanto and Bermudan: **MISSING** (FUT-016); margin and forced liquidation on a shortfall: **MISSING** (FUT-017). Crypto perpetuals: **VERIFIED**, funding from a `ScheduledFundingRate` at the run's mark. Fixed income: **VERIFIED** for fixed-rate bonds (was **PARTIAL**, FUT-010) -- `AssetType.BOND` booked by declared economics, `bond_schedule` applying coupons and redemption; accrual is carried by full prices, not booked separately. | +| (9) | `tests/integration/test_option_lifecycle.py` (58), `test_lifecycle_conventions.py` (9), `test_lifecycle_example.py` (4), `tests/integration/test_contract_expiry.py` (25), `test_bonds_on_the_path.py` (8), `test_scheduled_inputs.py::test_a_perpetuals_funding_is_charged_on_the_position_at_the_runs_mark`, `test_instrument_economics.py`, `test_v34_capabilities.py`; examples 33-39, 66. | +| (10) | The final price and an exercise's settlement value are the host's (the exchange's); the engine checks they are applied in the contract's window and are not negative, not that they are the exchange's. A delivery the book cannot pay for, or would go short on without permission, stops the run rather than being financed (FUT-017). | +| (11) | Done (FUT-004, FUT-010, FUT-012, FUT-013); FUT-014 (a strategy-facing exercise instruction), FUT-016 (richer terms), FUT-017 (margin). | +| (12) | Met for cash settlement, exercise, assignment and single-deliverable physical delivery, funding and coupons, applied from a schedule by `BacktestEngine.run` and `ReplayBacktest.run` and through a pause. | + +### 2.22 Market microstructure + +| | | +| --- | --- | +| (1) | Order-book snapshots in the market model (ADR-0011); no depth hook (ADR-0032 decision 1). | +| (2) | `alphalab/market/book.py`, `snapshot.py`, `events.py`. | +| (3) | `OrderBookSnapshot` published to market state. | +| (4) | `alphalab.market`. | +| (5)-(6) | market. | +| (7) | Depth reaches market state; **not** a strategy hook (BOUNDARY BDY-003). Quotes and trades do. | +| (8) | Quote/trade-driven strategies (spread, imbalance from top of book, inventory via positions): supported through `on_quote`/`on_trade`. Depth-driven strategies: **BOUNDARY** BDY-003. Queue position and fill probability: **MISSING**. Latency-sensitive trading: **BOUNDARY** (single-threaded, host-driven; LIM-006). | +| (9) | `tests/integration/test_trade_prints.py`, `test_streaming_session.py`. | +| (10) | A market-making backtest fills resting limits by the simulator's rule, not a queue model -- optimistic, and stated by `execution_assumptions`. | +| (11) | FUT-011 (queue/fill-probability model: new engine capability). | +| (12) | -- | + +### 2.23 Reproducibility, lineage and research-result identity + +| | | +| --- | --- | +| (1) | Fingerprints, manifests, certification, portability v3.6 (ADR-0041); engine build v3.11 (REP-002); host-independent records v4.1 (REP-005). | +| (2) | `alphalab/lifecycle/fingerprint.py`, `reproducibility.py`, `certification.py`. | +| (3) | `StrategyFingerprint`, `ReproducibilityManifest`, `RunDigest` (`result_id` over the whole recorded run), `EngineBuild`. | +| (4) | `alphalab.lifecycle`. | +| (5) | backtesting, data protocol, persistence. | +| (6) | -- (top of the graph). | +| (7) | R, B, Re, P, L. | +| (8) | **VERIFIED**. Input lineage: **VERIFIED** (was **PARTIAL**, FUT-005) -- `manifest_for_run(..., schedule=)` checks the schedule holds the run's deliveries (their count and its last delivery) and records its digest in the manifest identity; a combined dataset's parts are recorded and checked; `external_requirements` lists both. Both enter the identity only when present, so every v4.1 manifest keeps its id. A run's own record still names only its last delivery (ADR-0051 known limitation): the check is as strong as that record. | +| (9) | `tests/integration/test_v36_capabilities.py`; `tests/regression/test_run_record_is_host_independent.py`; `docs/audit/scripts/certify_release.py --check`; v4.1.1: example 72 reproduced in fresh interpreters under two hash seeds. | +| (10) | Certified identities are per host class for the standalone engines (LIM-005). | +| (11) | Done (FUT-005). | +| (12) | Met: a manifest of a run that read observations lists its schedule as an external requirement, checked against the run. | + +### 2.24 Lifecycle, promotion, deployment and the live boundary + +| | | +| --- | --- | +| (1) | ADR-0013 (v2.4), governance actors ADR-0018 (v2.16), lifecycle-to-execution `authorize_run` (v2.16), deployment manager PR-049. | +| (2) | `alphalab/lifecycle/`, `alphalab/model_registry/`, `alphalab/deployment_manager/`, `alphalab/experiment_tracking/`. | +| (3) | `LifecycleState`, `register_strategy`, `promote_strategy_version`, `approve_deployment`, `deploy_strategy_version`, `authorize_run`, evidence store. | +| (4) | `alphalab.lifecycle` (238 names). | +| (5) | 17 packages. | +| (6) | -- | +| (7) | Governs B -> P -> L; constructs nothing. | +| (8) | **VERIFIED** for the engine's contract. Real venues and providers: **EXTERNAL** (adapters are the host's; `tests/reference_adapter` is a test-tree example over a real socket). | +| (9) | `tests/integration/test_model_strategy_lifecycle.py`, `test_v35_capabilities.py`, `test_v36_capabilities.py`, `test_real_execution.py`, `test_live_session.py`. | +| (10) | Authentication, credentials, tenancy, RBAC: **BOUNDARY** (BDY-010, BND-002). | +| (11) | none. | +| (12) | met within the boundary. | + +### 2.25 Streaming market data and transports + +| | | +| --- | --- | +| (1) | ADR-0031 (v2.15). | +| (2) | `alphalab/marketdata/transport.py`, `websocket.py`; `alphalab/market/stream.py`, `source.py`. | +| (3) | RFC 6455 client, HTTP transport, `MarketDataSource`. | +| (4) | `alphalab.marketdata`, `alphalab.market`. | +| (5) | common, data. | +| (6) | market, runtime sessions. | +| (7) | P, L. | +| (8) | **VERIFIED** against local protocol servers; against a commercial endpoint **EXTERNAL**. | +| (9) | `tests/integration/test_streaming_market_data.py`, `test_streaming_session.py`. | +| (10) | No permessage-deflate (BND-006). | +| (11) | none. | +| (12) | met within the boundary. | + +### 2.27 Global market conventions, by capability (FUT-013 audit) + +Generic support means the engine reads a declaration and has no branch for a +market; it is not a claim that any market's actual rules are shipped. AlphaLab +ships no calendar, holiday, contract specification or settlement price for any +market (BDY-013): each is the host's declaration, and a missing one is refused +where it is needed. + +| Capability | Generic engine support | What a market must declare | Verified by | Limitation | +| --- | --- | --- | --- | --- | +| Time zones, sessions, breaks, holidays, early closes | `MarketCalendar` (IANA zone, weekly windows, holidays, special sessions); `VenueCalendars` per listing venue | the calendar | `test_lifecycle_conventions.py` (no-DST, two DST zones, Sunday-to-Thursday with a lunch break), `test_day_order_sessions.py` | overnight sessions spanning midnight are windows the caller states | +| Lifecycle instants | `session_close`, `settles_at` | the calendar and a `SettlementRule` | `test_lifecycle_conventions.py` | a date on a closed day is refused, not moved | +| Settlement lag (T+n) | `settlement_date`, `settles_at` compute it | the rule | `tests/unit/conventions/` | not booked: cash moves at the event (FUT-015) | +| Currencies and FX timing | settlement per instrument currency, `also_settles`, scheduled rates applied at their `as_of` | each currency's rates | `test_lifecycle_conventions.py::test_an_option_settling_in_another_currency_is_valued_at_the_rate_in_force`, `test_scheduled_inputs.py` | a delivery whose consideration and deliverable are in two currencies is refused (FUT-016) | +| Multipliers, lots, ticks, deliverable quantities | `InstrumentEconomics`, `LotSpecification`, `TickSchedule`, `Deliverable.units` | each contract's | `test_option_lifecycle.py` (multipliers 10 to 1,000, an adjusted deliverable of 150 against 100, an option on a future) | one deliverable per contract (FUT-016) | +| Exercise styles and windows | `OptionTerms` (`AMERICAN`, `EUROPEAN`, `exercisable_from`, `expires_at`) | the style and window | `test_option_lifecycle.py` | Bermudan and daily cut-offs not representable (FUT-016) | +| Automatic exercise and assignment | `AutomaticExercise` threshold, or none; assignment before expiry an input | the clearing house's rule | `test_option_lifecycle.py` | no probabilistic assignment model in the engine, by design | +| Cash and physical settlement | `SettlementStyle` on options and futures | the style and deliverable | `test_option_lifecycle.py` | no margin or liquidation on a shortfall (FUT-017) | +| Corporate actions on deliverables | splits and dividends on the delivered position | the action | `test_scheduled_inputs.py`, example 75 | a contract adjusted by a corporate action is re-declared by the caller, not adjusted by the engine | +| Market-specific identifiers | provider aliases and dated aliases on `InstrumentRecord` | the provider's symbols | `test_instrument_identity_reaches_a_fill.py` | -- | + +Required external data in every case: the calendar, contract specifications, +settlement prices and FX rates. Required execution-adapter capability on a live +path: a venue's own exercise, assignment and delivery reports, which arrive +through the broker boundary as fills and terminal outcomes; no adapter ships. +Acceptance to claim a market supported: its declared calendar and contract +terms, its settlement values, and a run reproducing a known lifecycle +end to end against them. + +### 2.26 Standalone research infrastructure + +| Package | Status | Note | +| --- | --- | --- | +| `scenario` | **VERIFIED**, standalone by design | Shocks any flat book; a test reads the source to keep it free of edges. | +| `scheduler` | **VERIFIED**; driver connection **VERIFIED** | Session and cron timers produce `TimerEvent`s; `RunSchedule.of(timers=...)` puts them on either historical driver (was a caller's `RunEngine.fire_timer`, FUT-004). | +| `distributed`, `cloud_research`, `cluster_scheduler` | **NOT E2E** for strategy work | Process-pool parameter sweeps; nothing joins them to a backtest's identity, though a sweep can call one per point. | +| `research_assistant` | **VERIFIED** as a deterministic grid driver | No LLM by design. | +| `reporting` | **VERIFIED** | Report exports; presentation is the host's. | +| `brokers` | **VERIFIED**, standalone | Many-venue registry, distinct from `broker` (pinned). | +| `feature_store` | **ISOLATED** | See 2.17. | + +--- + +## 3. The specific questions the brief asks + +| Question | Answer, with evidence | +| --- | --- | +| Research and factor composition | VERIFIED on the study path (2.17), and a strategy evaluating the study's definitions equals it (example 73). | +| Alternative- and fundamental-data integration | VERIFIED: ingestion with availability rules, sets, delivery to strategies on B (v3.12) and now Re/L/api (2.14). | +| Macro-data integration | VERIFIED as observations with period, release and vintage kept apart (2.16); `alphalab.macro` series lifted by `observations_from_indicator`. | +| External event delivery into backtest, replay and execution | B: VERIFIED since v3.12. Re, api, L: **closed in v4.1.1**, a durable paused replay included (replay schema 2). Paper over a source: host-delivered. | +| Social/news sentiment | VERIFIED as an input stream; scoring text is EXTERNAL (2.15). | +| Multiple asset classes in one strategy and portfolio | VERIFIED: equity + future in example 72; options and futures to expiry, perpetual funding, bonds and a two-currency book through the drivers (2.11, 2.21). Options exercised, assigned and delivered, and futures delivered, since the FUT-013 milestone (2.21). | +| ML/DL/RL training, validation, inference and persistence | Adaptive: VERIFIED. ML: VERIFIED end to end (example 73). DL: NOT E2E. RL: environment VERIFIED; stale defect claim fixed. | +| Multi-strategy allocation and attribution | VERIFIED (2.4, 2.20). | +| Execution algorithms evaluated historically | VERIFIED since the FUT-009 milestone: TWAP, VWAP, slicing, iceberg and participation work their children through both historical drivers (2.8). | +| Derivative lifecycle: exercise, assignment, delivery | VERIFIED since the FUT-013 milestone for American and European options, cash or one deliverable, and futures cash or delivered (2.21); the global-convention support boundary is 2.27. | +| Strategy state capture and restore | VERIFIED, at every record boundary of example 72 (2.2). | +| Research-to-strategy-to-execution data contracts | VERIFIED for prices and information; several datasets per run **added in v4.1.1** (2.13). | +| Strategy promotion, paper execution and production execution | Promotion and paper: VERIFIED. Production: the contract VERIFIED against a reference adapter; real venues EXTERNAL (2.24). | +| Streaming data and public API availability | VERIFIED locally (2.25); `alphalab.api` and documented modules are under the manifest (`docs/api/public_api.json`). | +| Actual broker/venue support versus adapter-only abstractions | **No vendor adapter ships.** `PaperBroker` is a deterministic local venue; `tests/reference_adapter` is a test-tree adapter over real HTTP; every named venue is the host's. | diff --git a/docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md b/docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md new file mode 100644 index 00000000..8202d465 --- /dev/null +++ b/docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md @@ -0,0 +1,170 @@ +# Strategy Families: From Research to Production (v4.1.1 audit, v4.2.0 candidate) + +What each family of quantitative strategy can do in AlphaLab today, judged from +source, tests and executed runs, and what stands between it and each stage. The +component-level evidence is [`STRATEGY_CAPABILITY_MATRIX.md`](STRATEGY_CAPABILITY_MATRIX.md) +(section numbers below refer to it); the work that would close each remaining +gap is a ledger entry and a ROADMAP "Future work" item. The audit raised +FUT-003 to FUT-012; its continuation delivered FUT-003 to FUT-008, FUT-010 and +FUT-012 and raised FUT-013, the FUT-009 milestone delivered FUT-009, and the +FUT-013 milestone delivered FUT-013 and raised FUT-014 to FUT-017, so what is +open is FUT-011 and FUT-014 to FUT-017 (FUT-002 predates the audit). Rows below +are as of the FUT-013 milestone. + +## Classes + +| Class | Meaning | +| --- | --- | +| **A** | Supported end to end and evidenced: a test or CI-run example drives a strategy of this kind through the canonical path. | +| **B** | Supported by existing components but missing a connection: the parts exist, something must still be wired (named). | +| **C** | Feasible through the generic strategy and data contracts, but needs external data or infrastructure, or a model the researcher supplies -- AlphaLab bundles none, by design. | +| **D** | Requires new engine capability (named). | +| **E** | Cannot reasonably be guaranteed by the engine itself (reason given). | + +A family is never marked A because one helper exists. Where its parts sit in +different classes, each is stated. "Researcher code" means a strategy written +against the documented contracts ([`../COMPOSING_STRATEGIES.md`](../COMPOSING_STRATEGIES.md)) +using what the context shows it -- no internal import, no engine change. + +## Stages + +R research (study, validation) - B backtest - Re replay - P paper - L live. +For every family: L against a real venue is **EXTERNAL** -- AlphaLab ships no +venue adapter; the contract is tested against `PaperBroker` and a reference +adapter over real HTTP (matrix 2.24). This is not repeated in each row. + +--- + +## 1. Price and systematic signals + +| Family | Class | Evidence / what is missing | +| --- | --- | --- | +| Trend following, breakout | **A** | A moving-average crossover from ingestion to a hand-checked, continued result (`tests/integration/test_strategy_building_end_to_end.py`); stop and stop-limit terms on the path (`test_order_terms.py`). Breakout levels are researcher code over `context.history`. | +| Time-series and cross-sectional momentum | **A** | Cross-sectional decisions on complete instants (`test_slices.py`); research to a traded book (`examples/10_complete_pipeline.py`, example gate); momentum in example 72 (`test_multi_asset_composition.py`). | +| Mean reversion, statistical reversal | **A** | An adaptive reversion strategy, backtest and replay agreeing (`test_v37_capabilities.py::test_backtest_and_replay_agree_for_an_adaptive_strategy`). | +| Calendar and seasonal effects | **A** | Calendars and sessions are on the path (matrix 2.10); timers are scheduled inputs on both historical drivers, fired after the records of their instant (`test_scheduled_inputs.py::test_a_timer_fires_after_its_instants_records_and_its_order_fills_at_the_next_price`). A seasonal rule itself is researcher code, not separately evidenced. | +| Technical indicators, custom transformations | **A** | Researcher code over bars and history (example 72's features); `alphalab.factor_library` primitives for research. | +| Volatility-scaled, regime-conditioned | **A** for regime conditioning (example 72's inflation regime; `test_v37_capabilities.py::test_regimes_are_detected_from_a_feature_and_condition_a_diagnostic`). Volatility scaling is researcher code over the same contracts, not separately evidenced. Statistical regime *models* (HMM, Markov switching) are a stated boundary (OFE-010): a researcher's model, fitted with its own window. | + +## 2. Quantitative and relative value + +| Family | Class | Evidence / what is missing | +| --- | --- | --- | +| Equity factor models; value, quality, growth, profitability, low volatility | **A** (research and construction) | Factor computation and studies with lineage (`test_v32_capabilities.py`, examples 17-24); point-in-time fundamentals with restatements (`test_v37_capabilities.py::test_fundamentals_are_read_as_knowable_and_restated_only_once_published`); a factor-neutral, sector-capped rebalance (`test_v38_capabilities.py`). A strategy evaluates the study's own `FeatureDefinition`s and equals the study panel at every instant (`test_model_on_the_execution_path.py::test_the_strategy_computes_the_studys_features_exactly`). | +| Statistical arbitrage, pairs, cointegration | **C** | Several instruments per strategy, slices, shorting and per-strategy targets are on the path; AlphaLab has no cointegration test or hedge-ratio estimator -- the researcher's model. Borrow availability and locate are external; a borrow fee is a `ScheduledCashFlow` on the run's schedule. | +| Residual, basket, cross-asset relative value | **C** | Neutralization (`factor_library`), multi-instrument intents; residual models are researcher code. Cross-currency baskets convert at scheduled rates (matrix 2.11). | +| Lead-lag, spread signals | **C** | Asynchronous series keep their own instants in a combined dataset (matrix 2.13); the lead-lag estimator is researcher code. | +| Market-neutral, factor-neutral construction | **A** | `construct` with constraints; factor-neutral rebalance (`test_v38_capabilities.py`). | + +## 3. Macro, fundamental and event-driven + +| Family | Class | Evidence / what is missing | +| --- | --- | --- | +| Global macro, systematic asset allocation | **A** | In one settlement currency, example 72 (an equity and an index future hedged on CPI); across currencies, a two-currency book funded and traded through the driver at the rate in force at each step (`test_scheduled_inputs.py::test_a_two_currency_book_is_funded_and_traded_through_the_driver`, `::test_the_driver_equals_a_loop_that_states_every_rate_by_hand`). | +| Economic releases, surprises, revisions, vintages | **A** | Period, release instant and vintage kept apart; every decision used the vintage published by then (`test_multi_asset_composition.py::test_every_decision_used_the_cpi_vintage_published_by_then`); actual-against-consensus as event measurements (example 50). | +| Inflation, growth, policy, yield-curve signals | **A** | From released figures, and from `alphalab.macro` indicator series lifted by `api.observations_from_indicator` with their vintages (`tests/unit/test_api_macro_lift.py`, checked against `macro.known_as_of`). Derived figures (a curve built from several released rates, a real rate) are researcher code over the lifted releases. | +| FX, futures, commodity and rates carry | Futures and commodity carry **A**: booking, chains, roll schedules and `factor_library.carry`; the roll is the strategy's trade, and a contract held to its last trading instant is cash-settled (`test_contract_expiry.py::test_a_future_held_to_expiry_is_closed_at_its_final_settlement`). A physically delivered future delivers its deliverable at its final price (`test_option_lifecycle.py::test_a_physically_settled_future_delivers_against_its_final_price`). FX carry **A** for the mechanics (scheduled rates and a stated interest `ScheduledCashFlow`), not separately evidenced as a strategy. Rates carry **A** for fixed-rate bonds (`test_bonds_on_the_path.py`). | +| Financial statements, valuation | **A** | Point-in-time statements, restatements, per-share restatement across splits (`test_external_information.py`), value and quality factors. | +| Earnings drift, analyst revisions, corporate actions | **A** for events delivered at availability and event studies anchored where they could first be traded (`test_v37_capabilities.py::test_events_are_anchored_where_they_could_first_be_traded`); splits, dividends and fees booked (`test_instrument_economics.py`), and applied from the run's schedule -- a per-share dividend on the position held at its ex-date, short or long (`test_scheduled_inputs.py::test_a_dividend_declared_per_share_pays_the_position_held_on_its_ex_date`). | +| News, event studies, event-driven | **A** | `on_observation` with subject subscriptions (`test_external_information.py`); `research.event_study`. | +| Regime identification, changing regimes | **A** for declared-rule regimes (research and trading); statistical regime models: boundary OFE-010 (researcher-supplied). | + +## 4. Alternative data + +| Family | Class | Evidence / what is missing | +| --- | --- | --- | +| Social-media sentiment | **A** as an input stream | Posts with a stated collection instant, a two-week-late post never used early, an undated post never delivered (`test_multi_asset_composition.py`). Scoring text is EXTERNAL: no NLP model, LLM or social API is in the engine. | +| News and textual sentiment | **A** for scores; **C** for text | As above; turning text into a score is the host's or the researcher's. | +| Entity-level sentiment and attention | **A** | Observations are keyed by the source's subject; subscriptions can name one (`observations:`). Mapping an entity to an instrument is the researcher's (by design: observations are not `asset_id`-keyed). | +| Fundamentals, web activity, alternative datasets | **A** | The observation vocabulary is open (`category`, `metric`); availability rules state when each row became knowable. | +| Satellite, weather, shipping, supply chain | **C** | Typed v1 records and the generic form exist; the data, its licensing and its availability semantics are the provider's. | +| Several external modalities combined | **A** | Example 72 combines sentiment and macro releases with two price series. | + +## 5. Derivatives and specialised markets + +| Family | Class | Evidence / what is missing | +| --- | --- | --- | +| Options volatility, skew, dispersion, Greeks | **A** in research (pricing, Greeks, IV, surfaces, American lattice: examples 35, 36, 66; Hull's references certified); **A** for trading cash-settled options to expiry -- premium times multiplier booked, exercise or assignment at intrinsic value, worthless expiry (`test_contract_expiry.py::test_an_option_is_settled_at_its_intrinsic_value`); **A** for options whose terms are declared -- American and European, cash or physical, exercised early in part, assigned by a supplied input, auto-exercised by a declared threshold, an adjusted deliverable, an option on a future (`test_option_lifecycle.py`, example 75); **D** for baskets, cash in lieu, quanto and Bermudan (FUT-016). Volatility and dispersion strategies themselves are researcher code over these. | +| Futures spreads, curve models, rolls, transitions | **A** in research (chains, roll schedules, continuous contracts: examples 33, 34); legs trade as instruments; **A** for cash-settled contract transitions on the path -- records after the last trading instant skipped, requests refused, positions settled and attributed (`test_contract_expiry.py`); **A** for physical delivery of one deliverable (`test_option_lifecycle.py`). | +| Fixed income, rates, credit, duration | **A** for fixed-rate bonds | `AssetType.BOND` booked by declared economics; `api.bond_schedule` turns `macro.Bond` terms into coupons and a redemption the run applies to whoever holds (`test_bonds_on_the_path.py`); analytics (yield, duration, convexity) in `alphalab.macro.bond`. Accrual is carried by trading full (dirty) prices; no separate accrued-interest entry (ADR-0051). Floating-rate and callable bonds are researcher-built schedules (**C**); credit models are excluded on purpose (ROADMAP boundaries). | +| Cross-currency strategies | **A** | Scheduled rates on both historical drivers and the facade; a missing rate is refused, never assumed (`test_scheduled_inputs.py`). | +| Crypto spot, perpetual funding, basis, cross-venue relative value | Spot **A** (a fully paid unit); perpetuals **A** for economics (`test_instrument_economics.py::test_a_perpetual_settles_like_a_future_and_pays_funding_as_a_cash_flow`), funding from a schedule **A** (`test_scheduled_inputs.py::test_a_perpetuals_funding_is_charged_on_the_position_at_the_runs_mark`); basis **C**; cross-venue **D** for realism -- two listings are two instruments, but one simulated run has one cost model, so venue-specific fees and latency are not modelled. | +| DeFi / AMM | **E** | No protocol, pool or on-chain execution model, and none is in scope: AMM pricing, gas, MEV and settlement finality are the protocol's and the host's. | + +## 6. Market microstructure and execution + +| Family | Class | Evidence / what is missing | +| --- | --- | --- | +| Market making, inventory control | **D** for honest evaluation | Quotes, resting limits, positions and costs are on the path; passive fills have no queue model, so results are optimistic (FUT-011). | +| Order-flow imbalance, liquidity, spread signals | **A** from top of book and trade prints (`on_quote`, `on_trade`; `test_trade_prints.py`); depth-driven signals are a boundary (BDY-003). | +| Queue-aware execution, fill probability | **D** | FUT-011. | +| Slicing: TWAP, VWAP, participation, slicing, iceberg | **A** | On the live and paper path (`test_v39_capabilities.py`) and, since the FUT-009 milestone, on both historical drivers: each child filled by the run's fill policy and cost model and settled on one parent order, its implementation shortfall measurable (`test_algorithms_on_the_historical_path.py`, example 74). Participation that completes after its window is refused. | +| Smart order routing across venues | **A** on the live and paper path (`select_route`, `test_v39_capabilities.py`); **D** historically -- a run simulates one venue and one cost model, so a choice between venues is not evaluated in a backtest. | +| Latency-sensitive strategies | **E** | The engine is single-threaded and driven by its host (LIM-006); co-location, kernel-bypass feeds and venue latency are infrastructure no library can guarantee. | +| Arbitrage constrained by fees, borrow, settlement, capital, liquidity | **B/C** | Fees, liquidity caps, strategy capital ceilings and settlement conventions are on the path; borrow availability is external; multi-currency capital converts at scheduled rates. | + +## 7. Statistical learning and adaptation + +| Family | Class | Evidence / what is missing | +| --- | --- | --- | +| Classical statistical models | **C** | Regression, shrinkage covariance and declared-rule regimes are bundled; other models are the researcher's. | +| Supervised and unsupervised learning | **A** supervised: a linear model fitted on purged labels known before a boundary, carried as declared parameters and state, traded after it and reproduced (`examples/73_train_and_trade_a_model.py`, `test_model_on_the_execution_path.py`); **C** unsupervised (none bundled). | +| Feature selection, ensembles | **C** | Researcher code; multiple-testing corrections and deflated Sharpe exist to judge the selection (matrix 2.18). | +| Deep learning, sequence models | **B** (dense, conv1d, LSTM and attention with checked gradients); a trained network is held by a strategy as example 73 holds its linear model, but no test trades one; large or GPU models **E** for the engine (pure Python, `dependencies = []`, OFE-026). | +| Reinforcement learning | **A** for the environment over the real pipeline (`tests/unit/reinforcement_learning/`); **B** for evaluating a trained agent with the research methodology. | +| Online learning, adaptive strategies | **A** | Immutable learned state with lineage, identical in research replay and on the path (`test_v37_capabilities.py`). | +| Researcher-defined algorithms and combinations | **A** | Example 72: a strategy no template anticipated, built from public contracts. | + +## 8. Portfolio and meta-strategy design + +| Family | Class | Evidence / what is missing | +| --- | --- | --- | +| Risk parity, robust optimisation | **A** | `test_v38_capabilities.py::test_risk_parity_across_strategies_equalizes_their_risk`. | +| Minimum variance, maximum diversification | **A** | `construct` with certified statuses (examples 56, 10). | +| Black-Litterman, constrained optimisation | **A** | `test_v38_capabilities.py::test_black_litterman_on_the_universe_with_a_view`. | +| Dynamic risk and capital allocation | **A** between runs (`allocation.capital`, `test_v38_capabilities.py::test_each_run_was_funded_with_exactly_its_allocation`); within one run a strategy's budget is configuration -- re-allocating mid-run is a meta-strategy's own sizing (**C**). | +| Strategy ensembles, regime-conditioned allocation | **A** for several strategies netted into one book with attribution (`test_v39_capabilities.py`); regime-conditioned allocation is a meta-strategy (**C**). | +| Cross-strategy exposure, concentration, attribution | **A** | `analytics.cross_strategy`; `test_v38_capabilities.py`. | +| Tail-risk protection, hedging | **A** for futures hedges (example 72) and scenario stress (`alphalab.scenario`, examples 29, 30); cash-settled option protection held through expiry **A** (`test_contract_expiry.py`); physically settled protection **A** (`test_option_lifecycle.py::test_an_exercised_put_delivers_shares_held_and_receives_the_strike`). | + +--- + +## Combinations + +The families are not independent, and the acceptance scenario exists to show +that the seams hold: example 72 combines a price signal (1), a macro regime +(3), social sentiment (4), a futures hedge (5), a researcher-defined algorithm +(7) and position sizing with a risk gate (8), through one run, one replay and +one continuation, with the time semantics of each input preserved. What breaks +a combination today is one of the named gaps -- FUT-011 (a queue model), +FUT-016 (contract terms beyond one deliverable) or FUT-017 (margin) -- and never +a missing strategy template; how its +orders are executed is now part of what a backtest measures (FUT-009). Example 72 now carries every timed input +on one schedule, and its replay pauses through a payload. + +## What would move the most families + +| Work | Families it moves | Release class | +| --- | --- | --- | +| FUT-011 queue model | market making, passive execution | minor, large | +| FUT-014 a strategy-facing exercise instruction | early-exercise and volatility strategies deciding inside the run | minor | +| FUT-016 richer contract terms | adjusted contracts after mergers, quanto options, Bermudan contracts | minor | +| FUT-015 / FUT-017 settlement-date accounting, margin | cash-constrained and short-option strategies, physically delivered commodities | major or a stated exception (payload steps) | + +Delivered by the continuation, with the release class each took: FUT-004 +scheduled inputs, FUT-012 cash settlement at expiry, FUT-005 the schedule in the +manifest (additive: manifests built without one keep their ids), FUT-006 and +FUT-007 features and a model on the path, FUT-008 macro series, FUT-010 bonds +(a new persisted enum member, additive), FUT-003 durable replay pause (replay +snapshot schema 2, with an upgrade), and, by the FUT-009 milestone, execution +algorithms on the historical drivers (a new routing member, additive; the +algorithm book is driver state, so no run's record moved), and, by the FUT-013 +milestone, the derivative lifecycle (instrument terms in the unreleased schema 4, +an `AssetType` member, schedule kinds) -- all minor. + +## Markets + +No family above is tied to a market. A market is a declared calendar, a +settlement rule, a currency and its contracts' terms; matrix section 2.27 sets +out what the engine does generically, what each market must declare, and where +the boundary is. Nothing here claims a market's actual rules are shipped. diff --git a/docs/audit/release_certification.json b/docs/audit/release_certification.json index c695d611..c51c3b88 100644 --- a/docs/audit/release_certification.json +++ b/docs/audit/release_certification.json @@ -2,13 +2,13 @@ "release": "4.1.0", "build": { "engine": "alphalab 4.1.0", - "source_digest": "188f514ee065496fcac686e611ca974e5e62f71129fa7d13fd9fb86f66221c84", + "source_digest": "a9ec495689a4d52df59e95b1d691263bbcd3d678123ce583498ed5eee0b9ce7a", "tz_database": "2024a" }, "environment": { "python": "3.12.4", "implementation": "CPython", - "platform": "macOS-26.6.2-arm64-arm-64bit" + "platform": "macOS-27.0.1-arm64-arm-64bit" }, "passed": true, "checks": [ @@ -133,10 +133,10 @@ "claim": "The build's public API is the one docs/api/public_api.json records for this release.", "passed": true, "evidence": { - "manifest_digest": "9f240d57df5666e02fc9f8c9d9e37bef2aaf1600bcac518543e83373c11d8090", + "manifest_digest": "074a8d0cde302752eb3ce92ad9699548fe46dd17331a9ebf674ee121068f5537", "packages": 44, "documented_modules": 12, - "exports": 2628, + "exports": 2667, "shared_names": 35 } } diff --git a/docs/audit/scripts/generate_v4_1_0_snapshot_fixtures.py b/docs/audit/scripts/generate_v4_1_0_snapshot_fixtures.py index e8c0f678..0cb6dbe2 100644 --- a/docs/audit/scripts/generate_v4_1_0_snapshot_fixtures.py +++ b/docs/audit/scripts/generate_v4_1_0_snapshot_fixtures.py @@ -27,7 +27,10 @@ def main(root: Path) -> None: from alphalab.persistence import serialize from tests.integration import replay_workload - assert REPLAY_SNAPSHOT_SCHEMA == 1, "this script freezes replay schema 1" + # Run against the v4.1.0 tree: the payload it freezes is replay schema 1, + # which later trees upgrade from and refuse to write. + schema: int = REPLAY_SNAPSHOT_SCHEMA + assert schema == 1, "this script freezes replay schema 1; run it on the v4.1.0 tree" paused = replay_workload.run(replay_workload.pause_at(11)) assert isinstance(paused, PausedReplay) payload = serialize(capture(paused)) diff --git a/examples/72_compose_a_multi_asset_strategy.py b/examples/72_compose_a_multi_asset_strategy.py new file mode 100644 index 00000000..47f74646 --- /dev/null +++ b/examples/72_compose_a_multi_asset_strategy.py @@ -0,0 +1,1046 @@ +""" +AlphaLab Examples +================= + +Example 72 : Compose a Multi-Asset Strategy from Prices, Sentiment and Macro Data + +Difficulty : Advanced + +Estimated Time : 25 minutes + +Prerequisites +------------- + +✓ Example 70 (build a strategy) +✓ Example 51 (alternative data with provenance) +✓ Example 33 (futures contracts and rolls) + +Topics +------ + +• Two price datasets from two venues -- a cash equity and an index future -- + read by one run under one derived identity (``combine_datasets``) +• Social-media sentiment and a macroeconomic series with revisions, each + ingested with the instant it became available, delivered to the strategy at + that instant and never earlier +• A strategy written by the researcher -- no template -- that turns those + inputs into features, a signal, a position in the equity and a hedge in the + future, and remembers what it has seen as declared state +• Execution costs, a pre-trade risk gate that refuses an order, and the + analytics, lineage and identities of the result +• The same run as a replay, paused and resumed, and as a backtest stopped, + stored and continued -- each the same result + +What this shows +--------------- + +v4.1.1's acceptance scenario: that a quantitative researcher can combine +heterogeneous inputs in one strategy through the public API, and that the +engine keeps each input's time semantics while doing so. Every row is written +out below and every figure is **synthetic**. Nothing here says anything about +whether sentiment or inflation predicts returns; the scenario proves that the +components compose, not that the strategy is profitable. + +The fixture is built to be dangerous. A post written on 8 April was only +collected on 22 April; March's inflation figure is revised on 24 April; May's +is published after the data ends; one post says nothing about when it became +available; the future misses one day's bar. The strategy logs what it knew at +every decision, and ``tests/integration/test_multi_asset_composition.py`` +proves from that log, and from runs whose late values are changed, that none +of it was used before it existed. + +Run + + python examples/72_compose_a_multi_asset_strategy.py +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass, replace +from datetime import date, datetime, time +from decimal import ROUND_HALF_EVEN, Decimal +from pathlib import Path +from typing import Any + +from alphalab.allocation.budget import CapitalBudget +from alphalab.allocation.constraints import AllocationConstraints +from alphalab.alt_data import ExternalObservation, delivery_schedule +from alphalab.alt_data.streaming import DeliverySchedule, ObservationDelivery +from alphalab.api import ( + AvailabilityFromColumn, + CombinedDataset, + ObservationColumns, + PointInTimeIngestion, + TimestampReading, + combine_datasets, + combined_market_dataset, + ingest_observations, + ingest_rows, + observation_source, +) +from alphalab.backtesting import ( + BacktestEngine, + BacktestResult, + ExecutionMode, + RunConfig, + replay_snapshot, +) +from alphalab.backtesting.engine import finalize +from alphalab.backtesting.replay import PausedReplay, ReplayBacktest +from alphalab.backtesting.schedule import RunSchedule, ScheduleCursor +from alphalab.backtesting.state import ReplayResult +from alphalab.common.ids import id_scope +from alphalab.common.order_terms import OrderTerms +from alphalab.common.point_in_time import VisibilityRule +from alphalab.common.types import ParamValue +from alphalab.conventions.economics import InstrumentEconomics, SettlementModel +from alphalab.conventions.lot import LotSpecification +from alphalab.core.enums import AssetType +from alphalab.data.calendar import MarketCalendar, SessionWindow +from alphalab.data.cleaning import REFUSE_EVERYTHING +from alphalab.data.corporate_actions import PriceBasis +from alphalab.data.dataset import Dataset +from alphalab.data.ingestion import IngestionRequest +from alphalab.data.source import RawSource, SourceKind, raw_source_from_bytes +from alphalab.data.symbols import DataAssetClass +from alphalab.data.time import BarStamp, TimeFrequency, TimestampFormat +from alphalab.execution.commission import PercentageCommission +from alphalab.execution.costs import ( + ExecutionCostModel, + FixedHalfSpread, + NoImpact, + NoTax, + PerTradeFee, +) +from alphalab.execution.policy import FillTiming, LiquidityCappedFill +from alphalab.execution.simulator import ExecutionSimulator +from alphalab.execution.slippage import PercentageSlippage +from alphalab.instrument.record import InstrumentRecord +from alphalab.instrument.registry import InstrumentRegistry, register_instruments +from alphalab.lifecycle import ( + NO_DEPENDENCIES, + LifecycleState, + ReproducibilityManifest, + StrategyFingerprint, + code_identity_for, + digest_run, + fingerprint_for_version, + get_strategy_version, + manifest_for_run, + register_strategy, + research_configuration, + running_build, + running_engine, +) +from alphalab.market.bar import TimeFrame +from alphalab.market.normalization import NormalizationPolicy +from alphalab.persistence import deserialize, serialize +from alphalab.portfolio.account import Account +from alphalab.risk.limits import ( + DailyLossLimit, + DrawdownLimit, + ExposureLimit, + LeverageLimit, + MarginLimit, + OrderSizeLimit, + PositionLimit, + RiskLimits, +) +from alphalab.runtime.execution_pipeline import ExecutionPipelineConfig +from alphalab.runtime.run import RunEngine +from alphalab.runtime.run_snapshot import RunObjects, capture, from_primitives, restore +from alphalab.runtime.snapshot import RuntimeObjects +from alphalab.strategy import ( + BaseStrategy, + DiscardingLogger, + FixedClock, + Intent, + IntentKind, + StrategyContext, + StrategyDefinition, + context_factory, +) +from alphalab.strategy.events import ObservationReceived, SliceClosed +from alphalab.strategy.protocol import StrategyProtocol +from alphalab.strategy.registry import StrategyClassRegistry, runtime_for +from alphalab.strategy.state import RuntimeState +from alphalab.strategy.supervisor import RuntimeSupervisor + +STRATEGY_ID = "EX72-COMPOSITE" +PROVIDER = "ex72-vendor" +SEED = 72 +START_CASH = Decimal("1000000.00") +#: The data's last instant is 31 May; a figure published after it is never read. +FIRST_DAY, LAST_DAY = date(2024, 4, 1), date(2024, 5, 31) + +#: New York's regular session. AlphaLab ships no holidays: the one in the window +#: is declared. +XNYS = MarketCalendar( + calendar_id="XNYS", + timezone_name="America/New_York", + weekly_sessions=dict.fromkeys(range(5), (SessionWindow(time(9, 30), time(16, 0)),)), + holidays=frozenset({date(2024, 5, 27)}), +) +SESSIONS: tuple[date, ...] = XNYS.sessions_between(FIRST_DAY, LAST_DAY) +_NEW_YORK = XNYS.local_datetime(0.0).tzinfo + +#: The future's daily settlement is stamped at 17:00 New York (16:00 Chicago), +#: an hour after the equity's close: two calendars, two instants a day, and +#: nothing aligns them but time itself. +EQUITY_CLOSE, FUTURE_SETTLE = "16:00:00", "17:00:00" +#: The future's vendor file has no bar for this session -- a gap, not a holiday. +FUTURE_GAP = date(2024, 5, 6) + +ACME = InstrumentRecord("ACME", AssetType.EQUITY, "XNYS", "USD", aliases={PROVIDER: "ACME"}) +#: An equity-index future: fifty index points a contract, variation margin +#: settled daily, traded in whole contracts. Without the declaration it could +#: not be booked at all (ledger ACC-005). +IDX = InstrumentRecord( + "IDXM4", + AssetType.FUTURE, + "XCME", + "USD", + aliases={PROVIDER: "IDXM4"}, + economics=InstrumentEconomics( + Decimal("50"), + SettlementModel.FUTURES_VARIATION, + lot=LotSpecification(Decimal("1"), Decimal("1")), + ), +) +REGISTRY: InstrumentRegistry = register_instruments(InstrumentRegistry(), (ACME, IDX)) + + +def _policy(venue: str) -> NormalizationPolicy: + return NormalizationPolicy( + bar_stamp=BarStamp.INTERVAL_END, + venue=venue, + currency="USD", + timeframe=TimeFrame.D1, + identity=REGISTRY, + provider=PROVIDER, + ) + + +EQUITY_POLICY, FUTURE_POLICY = _policy("XNYS"), _policy("XCME") +READING = TimestampReading(TimestampFormat.ISO_8601_NAIVE, "America/New_York", None) +CONTEXTS = context_factory(FixedClock(0.0), DiscardingLogger()) + + +def ny(text: str) -> float: + """A New York wall clock, ``"2024-04-10 08:30"``, as Unix seconds.""" + + return datetime.fromisoformat(text).replace(tzinfo=_NEW_YORK).timestamp() + + +def label(instant: float) -> str: + return XNYS.local_datetime(instant).strftime("%Y-%m-%d %H:%M") + + +# --------------------------------------------------------------------------- # +# 1. The data: two price files, a social feed, a macro release calendar +# --------------------------------------------------------------------------- # + + +def csv_bytes(rows: Sequence[Mapping[str, object]]) -> bytes: + """The rows as the CSV bytes they stand for, header first.""" + + header = list(rows[0]) + lines = [",".join(header), *(",".join(str(row[key]) for key in header) for row in rows)] + return "\n".join(lines).encode("utf-8") + + +def vendor_file(name: str, rows: Sequence[Mapping[str, object]], retrieved: str) -> RawSource: + return raw_source_from_bytes( + SourceKind.VENDOR_FILE, name, csv_bytes(rows), ny(retrieved), "text/csv", "utf-8" + ) + + +def _path(start: Decimal, pattern: Sequence[int], scale: Decimal) -> list[Decimal]: + """A deterministic price path: each session moves by ``pattern[i] * scale``.""" + + closes, price = [], start + for index in range(len(SESSIONS)): + price = (price * (1 + pattern[index % len(pattern)] * scale)).quantize(Decimal("0.01")) + closes.append(price) + return closes + + +#: Synthetic closes, written as a rule rather than drawn: a rise, a fall, a rise. +EQUITY_CLOSES = _path(Decimal("100"), (3, 4, -1, 5, 2, -2, 3, -6, -5, -4, 1, -7), Decimal("0.004")) +FUTURE_CLOSES = _path(Decimal("500"), (2, 1, -1, 3, -2, 1, 2, -3, -2, -1, 1, -2), Decimal("0.003")) + + +def bar_rows(symbol: str, closes: Sequence[Decimal], stamp: str) -> list[dict[str, str]]: + rows = [] + previous = closes[0] + for day, close in zip(SESSIONS, closes, strict=True): + if symbol == IDX.symbol and day == FUTURE_GAP: + previous = close + continue + high = max(previous, close) * Decimal("1.002") + low = min(previous, close) * Decimal("0.998") + rows.append( + { + "symbol": symbol, + "timestamp": f"{day.isoformat()} {stamp}", + "open": str(previous), + "high": str(high.quantize(Decimal("0.01"))), + "low": str(low.quantize(Decimal("0.01"))), + "close": str(close), + "volume": "250000" if symbol == ACME.symbol else "40000", + } + ) + previous = close + return rows + + +def ingest_prices( + name: str, rows: Sequence[Mapping[str, object]], asset_class: DataAssetClass +) -> Dataset: + """One price file as a versioned dataset; any defect at all refuses it.""" + + request = IngestionRequest( + name=name, + source=vendor_file(f"{name.lower()}.csv", rows, "2024-06-01 06:00"), + frequency=TimeFrequency.DAILY, + bar_stamp=BarStamp.INTERVAL_END, + asset_class=asset_class, + cleaning_policy=REFUSE_EVERYTHING, + price_basis=PriceBasis.RAW, + timezone_name="America/New_York", + ) + return ingest_rows(rows, request).dataset + + +def prices() -> CombinedDataset: + """Both files, read by the run as one input with one derived identity.""" + + equity = ingest_prices( + "EX72-ACME", bar_rows(ACME.symbol, EQUITY_CLOSES, EQUITY_CLOSE), DataAssetClass.EQUITY + ) + future = ingest_prices( + "EX72-IDX", bar_rows(IDX.symbol, FUTURE_CLOSES, FUTURE_SETTLE), DataAssetClass.FUTURE + ) + return combine_datasets([(equity, EQUITY_POLICY), (future, FUTURE_POLICY)]) + + +#: The post written on 8 April that the vendor only collected on 22 April. +LATE_POST = ("2024-04-08 10:00", "2024-04-22 09:00") + + +def sentiment_rows(late_score: str = "0.95") -> list[dict[str, str]]: + """One scored post a session about ACME, and four rows that are not what they seem. + + Out of order on purpose: the late post is the file's first row. + """ + + rows = [ + { + "ticker": "ACME", + "metric": "post_sentiment", + "score": late_score, + "posted_at": LATE_POST[0], + "collected_at": LATE_POST[1], + } + ] + for index, day in enumerate(SESSIONS): + score = Decimal(((index * 5) % 9) - 4) / Decimal(5) + rows.append( + { + "ticker": "ACME", + "metric": "post_sentiment", + "score": str(score), + "posted_at": f"{day.isoformat()} 12:00", + "collected_at": f"{day.isoformat()} 12:05", + } + ) + rows.append( # says nothing about when it became available: never delivered + { + "ticker": "ACME", + "metric": "post_sentiment", + "score": "-1.0", + "posted_at": "2024-04-15 11:00", + "collected_at": "", + } + ) + rows.append(dict(rows[5])) # an exact repeat: rejected with its line + rows.append( # unreadable: rejected with its line + { + "ticker": "ACME", + "metric": "post_sentiment", + "score": "n/a", + "posted_at": "2024-04-16 11:00", + "collected_at": "2024-04-16 11:01", + } + ) + return rows + + +SENTIMENT_COLUMNS = ObservationColumns( + category="sentiment", + unit="score", + subject="ticker", + metric="metric", + value="score", + observed_at="posted_at", + availability=AvailabilityFromColumn("collected_at"), + effective_at=None, + revision=None, + period=None, + ingested_at_retrieval=False, +) + + +def ingest_sentiment(late_score: str = "0.95") -> PointInTimeIngestion[ExternalObservation]: + rows = sentiment_rows(late_score) + raw = vendor_file("social_posts.csv", rows, "2024-06-01 06:00") + source = observation_source(raw, "social.posts", "2024.06") + return ingest_observations(rows, "social", source, SENTIMENT_COLUMNS, READING) + + +def macro_rows(march_revision: str = "3.40") -> list[dict[str, str]]: + """US CPI, year on year: each month's figure, each revision, each release instant. + + February's was published before the data starts, March's is revised on + 24 April, and May's is published after the data ends. + """ + + def row( + period: str, start: str, end: str, value: str, released: str, vintage: int + ) -> dict[str, str]: + return { + "region": "US", + "series": "cpi_yoy", + "value": value, + "period": period, + "period_start": f"{start} 00:00", + "period_end": f"{end} 23:59", + "released": released, + "vintage": str(vintage), + } + + return [ + row("2024-02", "2024-02-01", "2024-02-29", "3.20", "2024-03-12 08:30", 0), + row("2024-03", "2024-03-01", "2024-03-31", "3.50", "2024-04-10 08:30", 0), + row("2024-03", "2024-03-01", "2024-03-31", march_revision, "2024-04-24 08:30", 1), + row("2024-04", "2024-04-01", "2024-04-30", "3.70", "2024-05-15 08:30", 0), + row("2024-05", "2024-05-01", "2024-05-31", "3.10", "2024-06-12 08:30", 0), + ] + + +MACRO_COLUMNS = ObservationColumns( + category="economic", + unit="percent", + subject="region", + metric="series", + value="value", + observed_at="period_end", + availability=AvailabilityFromColumn("released"), + effective_at=None, + revision="vintage", + period=("period_start", "period_end", "period"), + ingested_at_retrieval=False, +) + + +def ingest_macro(march_revision: str = "3.40") -> PointInTimeIngestion[ExternalObservation]: + rows = macro_rows(march_revision) + raw = vendor_file("cpi_releases.csv", rows, "2024-06-13 06:00") + source = observation_source(raw, "macro.us_cpi", "2024.06") + return ingest_observations(rows, "us_cpi", source, MACRO_COLUMNS, READING) + + +@dataclass(frozen=True) +class Inputs: + """Everything one run reads, each piece with its own identity.""" + + prices: CombinedDataset + sentiment: PointInTimeIngestion[ExternalObservation] + macro: PointInTimeIngestion[ExternalObservation] + + def schedules(self) -> tuple[DeliverySchedule[ExternalObservation], ...]: + """Each set's records at the instant each was published -- never earlier.""" + + return tuple( + delivery_schedule(ingested.observations, VisibilityRule.PUBLICATION) + for ingested in (self.sentiment, self.macro) + ) + + def deliveries(self) -> tuple[ObservationDelivery[ExternalObservation], ...]: + return tuple(delivery for schedule in self.schedules() for delivery in schedule) + + def schedule(self) -> RunSchedule: + """Every timed input of the run: here, both information sets.""" + + return RunSchedule.of(observations=self.deliveries()) + + def parameters(self) -> dict[str, ParamValue]: + """The strategy's parameters, which name the information sets it reads.""" + + return { + **PARAMETERS, + "sentiment_set": self.sentiment.observations.version, + "macro_set": self.macro.observations.version, + } + + +def inputs(late_score: str = "0.95", march_revision: str = "3.40") -> Inputs: + return Inputs(prices(), ingest_sentiment(late_score), ingest_macro(march_revision)) + + +# --------------------------------------------------------------------------- # +# 2. The strategy: the researcher's, against the protocol +# --------------------------------------------------------------------------- # + +#: What drives its orders, and therefore what its fingerprint hashes. +PARAMETERS: dict[str, ParamValue] = { + "lookback": 5, + "momentum_weight": 20.0, + "sentiment_decay": 0.5, + "shares_per_unit": 1000, + "max_shares": 2000, + "hot_inflation": 3.5, + "hedge_ratio": 0.5, + "fresh_seconds": 7200.0, +} + +ZERO = Decimal("0") + + +@dataclass(frozen=True) +class Decision: + """What the strategy knew and wanted at one decision -- the evidence of its timing. + + Attributes: + at: The instant decided at. + seen: Every observation delivered to it so far, as ``(known_at, delivery_id)``. + cpi: The inflation reading it used: ``(period, vintage, value)``, or ``None``. + equity_target: The shares it asked to hold. + future_target: The contracts it asked to hold, or ``None`` when the + future's last price was too old to act on. + """ + + at: float + seen: tuple[tuple[float, str], ...] + cpi: tuple[str, int, str] | None + equity_target: Decimal + future_target: Decimal | None + + +class CompositeSignal(BaseStrategy): + """ACME momentum plus sentiment about it, hedged with the future while inflation runs hot. + + * **Features.** Five-session momentum of ACME's closes; an exponentially + decayed average of post sentiment; the latest *known* vintage of the most + recent inflation figure. + * **Signal.** ``momentum * momentum_weight + sentiment``, scaled to shares + and capped at ``max_shares`` either way, in hundreds. + * **Hedge.** While inflation is at or above ``hot_inflation``, short + ``hedge_ratio`` of the equity position's value in futures, in whole + contracts -- valued with the contract's multiplier, read from the + instrument registry the context exposes. + * **Freshness.** It decides on every completed instant, and only touches + the future when the future's last settlement is within ``fresh_seconds``: + on the day the future's bar is missing it says nothing about the future, + rather than hedge at yesterday's price. + + Orders go to the next session's opening auction (``OrderTerms.at_open()``): + a decision taken on a close is never filled at that close. + """ + + def __init__(self, strategy_id: str, parameters: Mapping[str, ParamValue]) -> None: + self._strategy_id = strategy_id + self._lookback = int(parameters["lookback"]) + self._momentum_weight = Decimal(str(parameters["momentum_weight"])) + self._decay = Decimal(str(parameters["sentiment_decay"])) + self._shares_per_unit = Decimal(str(parameters["shares_per_unit"])) + self._max_shares = Decimal(str(parameters["max_shares"])) + self._hot = Decimal(str(parameters["hot_inflation"])) + self._hedge_ratio = Decimal(str(parameters["hedge_ratio"])) + self._fresh = float(parameters["fresh_seconds"]) + # Declared state: everything below is captured and restored. + self._closes: list[Decimal] = [] + self._sentiment: Decimal | None = None + self._cpi: dict[str, tuple[float, int, Decimal]] = {} + self._seen: list[tuple[float, str]] = [] + self.decisions: list[Decision] = [] + + # -- what it is told ------------------------------------------------------ + + def on_bar(self, context: StrategyContext, event: Any) -> Iterable[Intent]: + bar = event.bar + if bar.asset_id == ACME.asset_id: + self._closes.append(bar.close) + return () + + def on_observation( + self, context: StrategyContext, event: ObservationReceived + ) -> Iterable[Intent]: + record = event.record + if not isinstance(record, ExternalObservation): + return () + self._seen.append((event.timestamp, event.delivery_id)) + if record.category == "sentiment": + previous = record.value if self._sentiment is None else self._sentiment + self._sentiment = self._decay * record.value + (1 - self._decay) * previous + elif record.metric == "cpi_yoy" and record.period is not None: + held = self._cpi.get(record.period.label) + if held is None or record.revision > held[1]: + self._cpi[record.period.label] = (record.period.end, record.revision, record.value) + return () + + # -- what it decides ------------------------------------------------------ + + def on_slice(self, context: StrategyContext, event: SliceClosed) -> Iterable[Intent]: + if len(self._closes) <= self._lookback: + return () + momentum = self._closes[-1] / self._closes[-1 - self._lookback] - 1 + signal = momentum * self._momentum_weight + (self._sentiment or ZERO) + shares = (signal * self._shares_per_unit / 100).quantize( + Decimal("1"), rounding=ROUND_HALF_EVEN + ) * 100 + # ``+ 0`` writes a zero target as 0, never -0. + equity_target = max(-self._max_shares, min(self._max_shares, shares)) + 0 + + cpi = self._latest_cpi() + future_target = self._hedge(context, event.timestamp, equity_target, cpi) + self.decisions.append( + Decision( + event.timestamp, + tuple(self._seen), + None if cpi is None else (cpi[0], cpi[1], str(cpi[2])), + equity_target, + future_target, + ) + ) + intents = [self._target(ACME.asset_id, equity_target, event.timestamp)] + if future_target is not None: + intents.append(self._target(IDX.asset_id, future_target, event.timestamp)) + return tuple(intents) + + def _latest_cpi(self) -> tuple[str, int, Decimal] | None: + if not self._cpi: + return None + period = max(self._cpi, key=lambda name: self._cpi[name][0]) + _, vintage, value = self._cpi[period] + return period, vintage, value + + def _hedge( + self, + context: StrategyContext, + at: float, + equity_target: Decimal, + cpi: tuple[str, int, Decimal] | None, + ) -> Decimal | None: + bar = context.market.bar(IDX.asset_id) + equity = context.market.price(ACME.asset_id) + if bar is None or equity is None or at - bar.timestamp > self._fresh: + return None + if cpi is None or cpi[2] < self._hot: + return ZERO + # The multiplier is the instrument's declaration, read from the run's + # registry -- never assumed. Without one a hedge cannot be sized. + instrument = context.universe.instrument(IDX.asset_id) + economics = None if instrument is None else instrument.economics + if economics is None: + raise ValueError(f"{IDX.symbol} declares no multiplier; a hedge cannot be sized") + multiplier: Decimal = economics.multiplier + contracts: Decimal = self._hedge_ratio * equity_target * equity / (bar.close * multiplier) + return -contracts.quantize(Decimal("1"), rounding=ROUND_HALF_EVEN) + + def _target(self, asset_id: str, quantity: Decimal, at: float) -> Intent: + return Intent( + strategy_id=self._strategy_id, + instrument=asset_id, + target=quantity, + timestamp=at, + kind=IntentKind.TARGET_QUANTITY, + terms=OrderTerms.at_open(), + ) + + # -- what it remembers ---------------------------------------------------- + + def strategy_state_version(self) -> int: + return 1 + + def capture_state(self) -> dict[str, Any]: + return { + "closes": [str(close) for close in self._closes], + "sentiment": None if self._sentiment is None else str(self._sentiment), + "cpi": { + period: [end, vintage, str(value)] + for period, (end, vintage, value) in sorted(self._cpi.items()) + }, + "seen": [[at, delivery] for at, delivery in self._seen], + } + + def restore_state(self, payload: Any, version: int) -> None: + if version != 1: + raise ValueError(f"CompositeSignal cannot read state version {version}") + self._closes = [Decimal(close) for close in payload["closes"]] + sentiment = payload["sentiment"] + self._sentiment = None if sentiment is None else Decimal(sentiment) + self._cpi = { + str(period): (float(end), int(vintage), Decimal(value)) + for period, (end, vintage, value) in payload["cpi"].items() + } + self._seen = [(float(at), str(delivery)) for at, delivery in payload["seen"]] + + +def composite_factory( + strategy_id: str, parameters: Mapping[str, ParamValue], / +) -> StrategyProtocol: + return CompositeSignal(strategy_id, parameters) + + +#: The one mapping from the strategy's identity to its code. A registry +#: discovers and versions a researcher's code; it does not limit what it may be. +CLASSES = StrategyClassRegistry().register(STRATEGY_ID, composite_factory) + + +def definition(data: Inputs) -> StrategyDefinition: + return StrategyDefinition( + strategy_id=STRATEGY_ID, + name="Composite signal with an inflation hedge", + version="1", + author="examples", + description="equity momentum + post sentiment, hedged with futures when CPI runs hot", + parameters=data.parameters(), + ) + + +def running(data: Inputs) -> RuntimeState: + """A fresh instance, built by the class registry from the definition, and started.""" + + state = runtime_for(CLASSES, (definition(data),)) + strategy = state.strategies[STRATEGY_ID] + strategy, _ = RuntimeSupervisor.configure(strategy, {}, 1.0) + strategy, _ = RuntimeSupervisor.initialize(strategy, 1.1) + strategy, _ = RuntimeSupervisor.subscribe( + strategy, frozenset({"bars", "slices", "observations"}), 1.2 + ) + strategy, _ = RuntimeSupervisor.start(strategy, 1.3) + return replace(state, strategies={STRATEGY_ID: strategy}) + + +def instance_of(result: BacktestResult) -> CompositeSignal: + instance = result.state.strategy.strategies[STRATEGY_ID].instance + assert isinstance(instance, CompositeSignal) + return instance + + +# --------------------------------------------------------------------------- # +# 3. The run: costs, limits, the canonical path +# --------------------------------------------------------------------------- # + +#: Every cost stated. The half-spread and fee are assumptions, and naming them +#: here is what puts them in the run's recorded configuration. +COSTS = ExecutionCostModel( + spread_model=FixedHalfSpread(Decimal("0.01")), + slippage_model=PercentageSlippage(Decimal("0.0005")), + impact_model=NoImpact(), + commission_model=PercentageCommission(Decimal("0.0001")), + fee_model=PerTradeFee(Decimal("1.00")), + tax_model=NoTax(), +) + +#: The pre-trade gate. No order may move more than 1,000 units at once -- so a +#: decision whose target jumps further is refused, the strategy keeps what it +#: holds, and the refusal is in the result rather than quietly resized. +LIMITS = RiskLimits( + order_size=OrderSizeLimit(Decimal("1000"), Decimal("1000000")), + position=PositionLimit(Decimal("1000000"), Decimal("2000000")), + exposure=ExposureLimit(Decimal("2000000"), Decimal("2000000")), + leverage=LeverageLimit(Decimal("2")), + margin=MarginLimit(Decimal("1")), + daily_loss=DailyLossLimit(Decimal("100000"), "America/New_York"), + drawdown=DrawdownLimit(Decimal("0.25")), +) + + +def run_config(seed: int | None = SEED) -> RunConfig: + return RunConfig( + mode=ExecutionMode.BACKTEST, + pipeline=ExecutionPipelineConfig( + account=Account("ACC-EX72", "USD", "Example 72", 1.0), + starting_cash=START_CASH, + budget=CapitalBudget( + global_capital=START_CASH, + maximum_exposure=START_CASH * 2, + cash_buffer=ZERO, + strategy_budgets={STRATEGY_ID: START_CASH}, + ), + allocation_constraints=AllocationConstraints( + allow_shorting=True, enforce_integer_quantities=True + ), + risk_limits=LIMITS, + simulator=ExecutionSimulator(cost_model=COSTS), + instruments=REGISTRY, + # A decision is never filled at the price that made it. + fill_timing=FillTiming.NEXT_EVENT, + ), + # No fill takes more than a tenth of the volume its bar printed. + fill_policy=LiquidityCappedFill(participation_rate=Decimal("0.1")), + seed=seed, + start_timestamp=1.0, + periods_per_year=252.0, + ) + + +def run(data: Inputs, seed: int | None = SEED) -> BacktestResult: + """Prices, information and a researcher's strategy, through the canonical path.""" + + return BacktestEngine.run( + run_config(seed), + combined_market_dataset(data.prices), + running(data), + CONTEXTS, + schedule=data.schedule(), + ) + + +def replayed(data: Inputs, pause_at: int | None = None) -> ReplayResult: + """The same run as a replay -- paused after ``pause_at`` records, if given, written to a + payload, restored with fresh objects as another process would, and resumed.""" + + config = run_config() + market = combined_market_dataset(data.prices) + if pause_at is None: + result = ReplayBacktest.run( + config, market, running(data), CONTEXTS, schedule=data.schedule() + ) + else: + paused = ReplayBacktest.run( + config, + market, + running(data), + CONTEXTS, + pause_when=lambda progress: progress.records_replayed == pause_at, + schedule=data.schedule(), + ) + assert isinstance(paused, PausedReplay) + payload = serialize(replay_snapshot.capture(paused)) + restored = replay_snapshot.restore( + replay_snapshot.from_primitives(deserialize(payload)), + fresh_objects(data), + market, + data.schedule(), + ) + result = ReplayBacktest.resume(restored, CONTEXTS) + assert isinstance(result, ReplayResult) + return result + + +def fresh_objects(data: Inputs) -> RunObjects: + """The live objects a fresh process builds again: the strategy from its registry.""" + + fresh = run_config() + return RunObjects( + pipeline=RuntimeObjects( + sizing_model=fresh.pipeline.sizing_model, + simulator=fresh.pipeline.simulator, + strategies={STRATEGY_ID: CLASSES.construct(STRATEGY_ID, data.parameters())}, + instruments=REGISTRY, + ), + fill_policy=fresh.fill_policy, + ) + + +def continued(data: Inputs, stop_after: int) -> BacktestResult: + """Stop after ``stop_after`` records, store the run, restore it with fresh objects, finish. + + :meth:`BacktestEngine.run`'s loop written out, with the cursor it uses: + :meth:`ScheduleCursor.start` given the record the run stopped after hands + the continuation exactly the inputs it has not applied. + """ + + config = run_config() + market = combined_market_dataset(data.prices) + initial = running(data) + assert config.seed is not None + with id_scope(config.seed): + state = replace(BacktestEngine.initialize(config, initial), source_id=market.dataset_id) + cursor = ScheduleCursor.start(data.schedule()) + for record in market.records[:stop_after]: + cursor, state = cursor.before(state, record.timestamp, CONTEXTS) + state = RunEngine.close_slice(state, CONTEXTS, cursor.rates, before=record.timestamp) + state, _ = RunEngine.advance(state, record, CONTEXTS, rates=cursor.rates) + payload = serialize(capture(state)) + + restored = restore(from_primitives(deserialize(payload)), fresh_objects(data)) + after = market.records[stop_after - 1].timestamp if stop_after else None + cursor = ScheduleCursor.start(data.schedule(), after) + with BacktestEngine.resume(restored): + for record in market.records[stop_after:]: + cursor, restored = cursor.before(restored, record.timestamp, CONTEXTS) + restored = RunEngine.close_slice( + restored, CONTEXTS, cursor.rates, before=record.timestamp + ) + restored, _ = RunEngine.advance(restored, record, CONTEXTS, rates=cursor.rates) + cursor, restored = cursor.finish(restored, CONTEXTS) + return finalize(RunEngine.close_slice(restored, CONTEXTS, cursor.rates)) + + +# --------------------------------------------------------------------------- # +# 4. Identity: the strategy's fingerprint, and the manifest of the result +# --------------------------------------------------------------------------- # + + +def fingerprint(data: Inputs) -> StrategyFingerprint: + state, reference = register_strategy(LifecycleState(), "ex72-composite", definition(data), 2.0) + version = get_strategy_version(state.strategies, reference.name, reference.version) + code = code_identity_for( + CLASSES.require(STRATEGY_ID), + "alphalab-examples", + "72", + {"72_compose_a_multi_asset_strategy.py": Path(__file__).read_bytes()}, + ) + research = research_configuration({"validation": "single backtest, synthetic data"}) + return fingerprint_for_version(version, code, NO_DEPENDENCIES, research, running_engine()) + + +def manifest(data: Inputs, result: BacktestResult) -> ReproducibilityManifest: + return manifest_for_run( + result, + data.prices, + fingerprint(data), + running_engine(), + build=running_build(), + schedule=data.schedule(), + ) + + +# --------------------------------------------------------------------------- # +# The walk-through +# --------------------------------------------------------------------------- # + + +def banner(title: str) -> None: + print() + print(f"-- {title} " + "-" * max(0, 66 - len(title))) + + +def main() -> None: + print("=" * 72) + print("AlphaLab Example 72 : Compose a Multi-Asset Strategy") + print("=" * 72) + + data = inputs() + provenance = data.prices.require_provenance() + + banner("1. Inputs, each with its own identity") + for version, content in provenance.parts: + print(f" prices {version} bytes {content[:16]}") + print(f" combined {provenance.dataset_version}") + print( + f" sentiment {data.sentiment.observations.version}: " + f"{len(data.sentiment.observations)} posts, {len(data.sentiment.rejected)} rows rejected" + ) + for rejection in data.sentiment.rejected: + print(f" line {rejection.line_number}: {rejection.findings[0].detail[:58]}") + sentiment, _ = data.schedules() + print(f" {sentiment.unverified} post with no stated availability, never delivered") + print(f" macro {data.macro.observations.version}: {len(data.macro.observations)} releases") + late = next(d for d in sentiment if d.record.stamp.observed_at == ny(LATE_POST[0])) + print( + f" the late post: written {label(late.record.stamp.observed_at)}, " + f"knowable {label(late.known_at)}" + ) + + banner("2. The run") + result = run(data) + strategy = instance_of(result) + print( + f" records {result.records_processed}, observations delivered " + f"{result.run.observations_delivered} of {len(data.deliveries())}" + ) + print( + f" orders {len(result.orders)}, fills {len(result.fills)}, decisions " + f"{len(strategy.decisions)}" + ) + refused = [decision for decision in result.state.risk.history if not decision.approved] + print(f" refused by the risk gate: {len(refused)}") + for decision in refused[:2]: + (violation,) = decision.violations + print( + f" {label(decision.timestamp)} {violation.rule}: " + f"{violation.current_value} > {violation.allowed_value}" + ) + stale = [d for d in strategy.decisions if d.future_target is None] + print(f" decisions that left the future alone (its price too old): {len(stale)}") + positions = result.state.portfolio.positions + for record in (ACME, IDX): + held = positions.get(record.asset_id) + print(f" {record.symbol:6} {'0' if held is None else held.quantity}") + + banner("3. Analytics") + valuation = result.valuation + print( + f" equity {valuation.equity} realized {valuation.realized_pnl} " + f"unrealized {valuation.unrealized_pnl}" + ) + print(f" costs paid {result.state.portfolio.commission_paid['USD']}") + report = result.report + assert report is not None + print( + f" total return {report.returns.total_return:.6f} " + f"max drawdown {report.drawdowns.max_drawdown:.6f}" + ) + for record in (ACME, IDX): + pnl = report.attribution.pnl_by_asset.get(record.asset_id, ZERO) + print(f" closed-trade P&L, {record.symbol:6} {pnl}") + print(" (the future's daily variation margin is realized as it settles, so the") + print(" closed-trade figures need not sum to the realized total)") + optimistic = ", ".join(result.execution_assumptions.optimistic) + print(f" optimistic execution assumptions: {optimistic or 'none'}") + + banner("4. Identity and reproducibility") + digest = digest_run(result) + evidence = manifest(data, result) + strategy_fingerprint = evidence.fingerprint + assert strategy_fingerprint is not None + print(f" result {digest.result_id}") + print(f" dataset {evidence.dataset_version}") + print(f" strategy {strategy_fingerprint.fingerprint}") + print(f" schedule {evidence.schedule_digest}") + print(f" manifest {evidence.manifest_id}") + again = digest_run(run(data)) + print(f" rerun {'identical' if again.result_id == digest.result_id else 'DIFFERENT'}") + + banner("5. Replay, pause and resume, stop and continue") + reference = result.state + replay = replayed(data) + print(f" replay fills {'equal' if replay.backtest.fills == result.fills else 'DIFFER'}") + resumed = replayed(data, pause_at=len(combined_market_dataset(data.prices)) // 2) + print( + f" paused at half, written down, restored and resumed: " + f"{'equal' if digest_run(resumed.backtest) == digest_run(replay.backtest) else 'DIFFER'}" + ) + stopped = continued(data, stop_after=37) + print( + f" stored at 37 records and continued: " + f"{'equal' if digest_run(stopped).result_id == digest.result_id else 'DIFFER'}" + ) + assert reference is result.state + + banner("6. What a late value changes, and when") + altered = run(inputs(late_score="-0.95", march_revision="3.90")) + first_change = next( + ( + (a.at, b.at) + for a, b in zip(strategy.decisions, instance_of(altered).decisions, strict=True) + if (a.equity_target, a.future_target) != (b.equity_target, b.future_target) + ), + None, + ) + print(" the late post and March's revision, changed:") + print(f" first decision that differs: {label(first_change[0]) if first_change else 'none'}") + print( + f" (the post became knowable {label(ny(LATE_POST[1]))}, " + f"the revision {label(ny('2024-04-24 08:30'))})" + ) + + +if __name__ == "__main__": + main() diff --git a/examples/73_train_and_trade_a_model.py b/examples/73_train_and_trade_a_model.py new file mode 100644 index 00000000..ccdc423d --- /dev/null +++ b/examples/73_train_and_trade_a_model.py @@ -0,0 +1,454 @@ +""" +AlphaLab Examples +================= + +Example 73 : Train a Model on the Past and Trade It Forward + +Difficulty : Advanced + +Estimated Time : 15 minutes + +Prerequisites +------------- + +✓ Example 70 (build a strategy) +✓ Example 17 (feature engineering) +✓ Example 20 (walk-forward validation) + +Topics +------ + +• Two features defined once, as ``FeatureDefinition``s, and computed by the + study path over a versioned dataset +• A linear model fitted only on samples whose label was knowable before a + boundary -- the sample whose forward return reaches across it is purged +• The same definitions evaluated inside the strategy, on the execution path, + from the closes it has been shown -- and equal to the study's values +• The fitted coefficients as the strategy's declared parameters and state: + fingerprinted, captured, restored exactly +• Trading only after the boundary, with costs, and reproducing the run + +What this shows +--------------- + +v4.1.1's evidence for ledger FUT-006 and FUT-007: research outputs -- features +and a fitted model -- reach the canonical engine path without a second +definition of either, and without information from after the boundary. The +prices are **synthetic**; nothing here says the model predicts anything. +``tests/integration/test_model_on_the_execution_path.py`` holds it to that. + +Run + + python examples/73_train_and_trade_a_model.py +""" + +from __future__ import annotations + +import json +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, replace +from decimal import Decimal +from typing import Any + +from alphalab.allocation.budget import CapitalBudget +from alphalab.allocation.constraints import AllocationConstraints +from alphalab.api import backtest, ingest_rows, observe +from alphalab.backtesting import BacktestResult, ExecutionMode, RunConfig +from alphalab.common.types import ParamValue +from alphalab.core.enums import AssetType +from alphalab.data import ( + REFUSE_EVERYTHING, + DataAssetClass, + Dataset, + IngestionRequest, + PriceBasis, + SourceKind, + TimeFrequency, + raw_source_from_bytes, +) +from alphalab.data.time import BarStamp +from alphalab.execution.commission import PerShareCommission +from alphalab.execution.policy import FillTiming +from alphalab.execution.simulator import ExecutionSimulator +from alphalab.factor_library import FeatureDefinition, FeatureField, FeatureKind +from alphalab.factor_library.compute import compute_feature +from alphalab.factor_library.observations import ObservationFrame, ObservationSeries +from alphalab.instrument.record import InstrumentRecord +from alphalab.instrument.registry import InstrumentRegistry, register_instruments +from alphalab.lifecycle import digest_run +from alphalab.market.bar import TimeFrame +from alphalab.market.normalization import NormalizationPolicy +from alphalab.ml import LinearRegressionModel, predict_linear, train_linear_regression +from alphalab.portfolio.account import Account +from alphalab.risk.limits import ( + DailyLossLimit, + DrawdownLimit, + ExposureLimit, + LeverageLimit, + MarginLimit, + OrderSizeLimit, + PositionLimit, + RiskLimits, +) +from alphalab.runtime.execution_pipeline import ExecutionPipelineConfig +from alphalab.strategy import ( + BaseStrategy, + DiscardingLogger, + FixedClock, + Intent, + IntentKind, + StrategyContext, + StrategyDefinition, + context_factory, +) +from alphalab.strategy.protocol import StrategyProtocol +from alphalab.strategy.registry import StrategyClassRegistry, runtime_for +from alphalab.strategy.state import RuntimeState +from alphalab.strategy.supervisor import RuntimeSupervisor + +STRATEGY_ID = "EX73-MODEL" +SYMBOL = "SYN" +PROVIDER = "synthetic" +SEED = 73 +START_CASH = Decimal("1000000.00") +DAY = 86_400.0 +FIRST_BAR = 1_704_153_600.0 +SESSIONS = 120 +#: Samples before this session train the model; the strategy trades from it on. +BOUNDARY = 80 +#: The label is the next session's return: one session ahead. +HORIZON = 1 + +INSTRUMENT = InstrumentRecord(SYMBOL, AssetType.EQUITY, "XSYN", "USD", aliases={PROVIDER: SYMBOL}) +REGISTRY: InstrumentRegistry = register_instruments(InstrumentRegistry(), (INSTRUMENT,)) +NORMALIZATION = NormalizationPolicy( + bar_stamp=BarStamp.INTERVAL_END, + venue="XSYN", + currency="USD", + timeframe=TimeFrame.D1, + identity=REGISTRY, + provider=PROVIDER, +) +CONTEXTS = context_factory(FixedClock(FIRST_BAR), DiscardingLogger()) + +#: The two features, defined once and read by the study and the strategy alike. +FEATURES = ( + FeatureDefinition("mom_5", FeatureKind.MOMENTUM, FeatureField.CLOSE, window=5), + FeatureDefinition("z_10", FeatureKind.ROLLING_ZSCORE, FeatureField.CLOSE, window=10), +) +FEATURE_NAMES = tuple(definition.feature_id for definition in FEATURES) + + +# --------------------------------------------------------------------------- # +# 1. Data +# --------------------------------------------------------------------------- # + + +def closes(shock: Decimal = Decimal("0")) -> list[Decimal]: + """A synthetic path with some persistence; ``shock`` moves every close after the boundary.""" + + pattern = (3, 2, -1, 4, -2, -3, 1, 2, -4, 3, -1, -2, 2) + out, price = [], Decimal("100") + for index in range(SESSIONS): + step = Decimal(pattern[index % len(pattern)]) / Decimal(250) + price = (price * (1 + step)).quantize(Decimal("0.01")) + out.append(price + (shock if index >= BOUNDARY else Decimal("0"))) + return out + + +def dataset(shock: Decimal = Decimal("0")) -> Dataset: + rows = [ + { + "timestamp": str(FIRST_BAR + index * DAY), + "symbol": SYMBOL, + "open": str(close), + "high": str(close), + "low": str(close), + "close": str(close), + "volume": "1000000", + } + for index, close in enumerate(closes(shock)) + ] + payload = json.dumps(rows, sort_keys=True).encode("utf-8") + request = IngestionRequest( + name="EX73-SYNTHETIC", + source=raw_source_from_bytes( + SourceKind.IN_MEMORY, "synthetic://example-73", payload, FIRST_BAR, "application/json" + ), + frequency=TimeFrequency.DAILY, + bar_stamp=BarStamp.INTERVAL_END, + asset_class=DataAssetClass.EQUITY, + cleaning_policy=REFUSE_EVERYTHING, + price_basis=PriceBasis.RAW, + ) + return ingest_rows(rows, request).dataset + + +# --------------------------------------------------------------------------- # +# 2. The study: features over the dataset, and a model fitted before the boundary +# --------------------------------------------------------------------------- # + + +def study_features(data: Dataset) -> dict[str, dict[float, float]]: + """Each feature's value at each instant, computed by the study path.""" + + frame = observe(data, FeatureField.CLOSE) + out: dict[str, dict[float, float]] = {} + for definition in FEATURES: + (series,) = compute_feature(definition, frame) + out[definition.feature_id] = dict(zip(series.timestamps, series.values, strict=True)) + return out + + +@dataclass(frozen=True) +class Training: + """What was fitted, and on what.""" + + model: LinearRegressionModel + sample_times: tuple[float, ...] + boundary_time: float + + +def fit(data: Dataset) -> Training: + """Fit on every sample whose features *and* label were known before the boundary. + + A sample at session ``t`` is labelled with the return to ``t + HORIZON``; + it is used only if ``t + HORIZON`` is before the boundary -- the purge that + stops the last training label from reaching into the trading period. + """ + + frame = observe(data, FeatureField.CLOSE) + (row,) = frame.series.values() + stamps, values = row.timestamps, row.values + boundary_time = stamps[BOUNDARY] + features = study_features(data) + x: list[tuple[float, ...]] = [] + y: list[float] = [] + times: list[float] = [] + for index in range(len(stamps) - HORIZON): + if stamps[index + HORIZON] >= boundary_time: + break + at = stamps[index] + if not all(at in features[name] for name in FEATURE_NAMES): + continue + x.append(tuple(features[name][at] for name in FEATURE_NAMES)) + y.append(values[index + HORIZON] / values[index] - 1) + times.append(at) + model = train_linear_regression(FEATURE_NAMES, tuple(x), tuple(y), l2_penalty=0.01) + return Training(model, tuple(times), boundary_time) + + +# --------------------------------------------------------------------------- # +# 3. The strategy: the same features, the fitted model, the canonical path +# --------------------------------------------------------------------------- # + + +def model_parameters(training: Training) -> dict[str, ParamValue]: + """The model as the strategy's declared parameters -- so its fingerprint hashes it.""" + + parameters: dict[str, ParamValue] = { + "intercept": training.model.intercept, + "l2_penalty": training.model.l2_penalty, + "boundary_time": training.boundary_time, + "size": 1000, + } + for name, coefficient in zip(FEATURE_NAMES, training.model.coefficients, strict=True): + parameters[f"coef.{name}"] = coefficient + return parameters + + +class ModelStrategy(BaseStrategy): + """Predicts the next return from the two features; holds ``size`` shares long or short. + + The features are computed with the study's own definitions over the closes + the strategy has been shown, so they cannot see a later close. Orders are + placed only from the boundary on. + """ + + def __init__(self, strategy_id: str, parameters: Mapping[str, ParamValue]) -> None: + self._strategy_id = strategy_id + self._model = LinearRegressionModel( + FEATURE_NAMES, + tuple(float(parameters[f"coef.{name}"]) for name in FEATURE_NAMES), + float(parameters["intercept"]), + float(parameters["l2_penalty"]), + ) + self._boundary = float(parameters["boundary_time"]) + self._size = Decimal(str(parameters["size"])) + self._times: list[float] = [] + self._closes: list[float] = [] + self.seen: dict[float, tuple[float, ...]] = {} + + def on_bar(self, context: StrategyContext, event: Any) -> Iterable[Intent]: + bar = event.bar + self._times.append(bar.timestamp) + self._closes.append(float(bar.close)) + frame = ObservationFrame( + FeatureField.CLOSE, + {SYMBOL: ObservationSeries(SYMBOL, tuple(self._times), tuple(self._closes))}, + "UTC", + ) + values = [] + for definition in FEATURES: + (series,) = compute_feature(definition, frame) + if not series.timestamps or series.timestamps[-1] != bar.timestamp: + return () + values.append(series.values[-1]) + self.seen[bar.timestamp] = tuple(values) + if bar.timestamp < self._boundary: + return () + (prediction,) = predict_linear(self._model, (tuple(values),)) + target = self._size if prediction > 0 else -self._size + return ( + Intent( + strategy_id=self._strategy_id, + instrument=INSTRUMENT.asset_id, + target=target, + timestamp=bar.timestamp, + kind=IntentKind.TARGET_QUANTITY, + ), + ) + + def strategy_state_version(self) -> int: + return 1 + + def capture_state(self) -> dict[str, Any]: + return { + "times": list(self._times), + "closes": list(self._closes), + "model": { + "coefficients": list(self._model.coefficients), + "intercept": self._model.intercept, + "l2_penalty": self._model.l2_penalty, + }, + } + + def restore_state(self, payload: Any, version: int) -> None: + if version != 1: + raise ValueError(f"ModelStrategy cannot read state version {version}") + self._times = [float(value) for value in payload["times"]] + self._closes = [float(value) for value in payload["closes"]] + model = payload["model"] + restored = LinearRegressionModel( + FEATURE_NAMES, + tuple(float(value) for value in model["coefficients"]), + float(model["intercept"]), + float(model["l2_penalty"]), + ) + if restored != self._model: + raise ValueError("the captured model is not the one this strategy was built with") + + +def model_factory(strategy_id: str, parameters: Mapping[str, ParamValue], /) -> StrategyProtocol: + return ModelStrategy(strategy_id, parameters) + + +CLASSES = StrategyClassRegistry().register(STRATEGY_ID, model_factory) + + +def running(training: Training) -> RuntimeState: + definition = StrategyDefinition( + strategy_id=STRATEGY_ID, + name="Linear model on momentum and z-score", + version="1", + author="examples", + description="fitted before the boundary, traded after it", + parameters=model_parameters(training), + ) + state = runtime_for(CLASSES, (definition,)) + strategy = state.strategies[STRATEGY_ID] + strategy, _ = RuntimeSupervisor.configure(strategy, {}, 1.0) + strategy, _ = RuntimeSupervisor.initialize(strategy, 1.1) + strategy, _ = RuntimeSupervisor.subscribe(strategy, frozenset({"bars"}), 1.2) + strategy, _ = RuntimeSupervisor.start(strategy, 1.3) + return replace(state, strategies={STRATEGY_ID: strategy}) + + +def run_config() -> RunConfig: + ample = Decimal("1000000000") + return RunConfig( + mode=ExecutionMode.BACKTEST, + pipeline=ExecutionPipelineConfig( + account=Account("ACC-EX73", "USD", "Example 73", 1.0), + starting_cash=START_CASH, + budget=CapitalBudget( + global_capital=START_CASH, + maximum_exposure=START_CASH, + cash_buffer=Decimal("0"), + strategy_budgets={STRATEGY_ID: START_CASH}, + ), + allocation_constraints=AllocationConstraints( + allow_shorting=True, enforce_integer_quantities=True + ), + risk_limits=RiskLimits( + order_size=OrderSizeLimit(ample, ample), + position=PositionLimit(ample, ample), + exposure=ExposureLimit(ample, ample), + leverage=LeverageLimit(Decimal("2")), + margin=MarginLimit(Decimal("1")), + daily_loss=DailyLossLimit(ample, "UTC"), + drawdown=DrawdownLimit(Decimal("1")), + ), + simulator=ExecutionSimulator(commission_model=PerShareCommission(Decimal("0.01"))), + instruments=REGISTRY, + fill_timing=FillTiming.NEXT_EVENT, + ), + seed=SEED, + start_timestamp=FIRST_BAR - 1.0, + ) + + +def run(data: Dataset, training: Training) -> BacktestResult: + return backtest(run_config(), data, running(training), CONTEXTS, NORMALIZATION) + + +def instance_of(result: BacktestResult) -> ModelStrategy: + instance = result.state.strategy.strategies[STRATEGY_ID].instance + assert isinstance(instance, ModelStrategy) + return instance + + +def agreement(data: Dataset, result: BacktestResult) -> int: + """How many instants the strategy's features equal the study's exactly.""" + + study = study_features(data) + seen = instance_of(result).seen + for at, values in seen.items(): + expected = tuple(study[name][at] for name in FEATURE_NAMES) + if values != expected: + raise AssertionError(f"at {at} the strategy computed {values}, the study {expected}") + return len(seen) + + +def main() -> None: + print("=" * 72) + print("AlphaLab Example 73 : Train a Model on the Past and Trade It Forward") + print("=" * 72) + data = dataset() + training = fit(data) + print(f"\n dataset {data.dataset_version}") + last = round((training.sample_times[-1] - FIRST_BAR) / DAY) + print( + f" trained on {len(training.sample_times)} samples, the last at session {last}; " + f"the boundary is session {BOUNDARY}" + ) + coefficients = ", ".join(f"{c:+.6f}" for c in training.model.coefficients) + print(f" model intercept {training.model.intercept:+.6f}, coefficients {coefficients}") + shocked = fit(dataset(shock=Decimal("25"))) + unchanged = shocked.model == training.model + print(f" prices after the boundary changed: model {'unchanged' if unchanged else 'CHANGED'}") + + result = run(data, training) + print(f"\n features equal to the study's at {agreement(data, result)} instants") + first = min(order.created_at for order in result.orders) + print( + f" first order at session {round((first - FIRST_BAR) / DAY)}; " + f"{len(result.fills)} fills; equity {result.valuation.equity}" + ) + print(f" result {digest_run(result).result_id}") + again = run(dataset(), fit(dataset())) + print(f" rerun {'identical' if digest_run(again) == digest_run(result) else 'DIFFERENT'}") + + +if __name__ == "__main__": + main() diff --git a/examples/74_evaluate_an_execution_algorithm.py b/examples/74_evaluate_an_execution_algorithm.py new file mode 100644 index 00000000..b1009d1c --- /dev/null +++ b/examples/74_evaluate_an_execution_algorithm.py @@ -0,0 +1,458 @@ +""" +AlphaLab Examples +================= + +Example 74 : Evaluate an Execution Algorithm in a Backtest + +Difficulty : Advanced + +Estimated Time : 15 minutes + +Prerequisites +------------- + +✓ Example 63 (execution algorithms) +✓ Example 65 (execution analytics) +✓ Example 70 (build a strategy) + +Topics +------ + +• One decision -- buy 3,000 shares -- executed five ways over the same + historical minute bars: whole, and by TWAP, VWAP, participation and iceberg +• Each algorithm's children released at the bars, filled by the run's own + liquidity cap and cost model, and settled on one parent OMS order +• What each run paid: spread, square-root impact, commission per fill, and the + quantity it never filled, measured as implementation shortfall +• The replay equal to the backtest, and a replay paused with a child working, + written to JSON and resumed to the same result + +What this shows +--------------- + +v4.1.1's evidence for ledger FUT-009: how a strategy's orders are executed is +part of what a backtest measures. A strategy decides *what* to own; an +:class:`~alphalab.backtesting.ExecutionPlan` decides how its orders are worked, +and every child goes through the canonical path -- the fill policy, the cost +model, the OMS, the portfolio, the allocation reservation -- so the comparison +below is between execution choices, not between engines. The bars are +**synthetic** and the VWAP profile is a stated shape, not a forecast. +``tests/integration/test_algorithms_on_the_historical_path.py`` holds the +mechanism to its properties. + +Run + + python examples/74_evaluate_an_execution_algorithm.py +""" + +from __future__ import annotations + +import math +from collections.abc import Iterable +from dataclasses import dataclass, replace +from decimal import ROUND_HALF_EVEN, Decimal +from typing import Any + +from alphalab.allocation.budget import CapitalBudget +from alphalab.allocation.constraints import AllocationConstraints +from alphalab.backtesting import ( + AlgorithmAssignment, + AlgorithmicResult, + BacktestEngine, + BacktestResult, + ExecutionMode, + ExecutionPlan, + MarketDataset, + PausedReplay, + ReplayBacktest, + RunConfig, + RunSchedule, +) +from alphalab.backtesting.algorithmic import capture_book +from alphalab.backtesting.replay_snapshot import capture, from_primitives, restore +from alphalab.core.contribution import StrategyContribution +from alphalab.core.enums import AssetType, OrderStatus +from alphalab.core.order_request import OrderRequest +from alphalab.execution.algorithms import ( + TWAP, + VWAP, + ExecutionAlgorithm, + Iceberg, + IncompletePolicy, + IntervalVolume, + MissingVolumePolicy, + Participation, + Urgency, + VolumeProfile, +) +from alphalab.execution.commission import FixedCommission +from alphalab.execution.costs import ( + ExecutionCostModel, + FixedHalfSpread, + NoFee, + NoSlippage, + NoTax, + SquareRootImpact, +) +from alphalab.execution.policy import FillTiming, LiquidityCappedFill +from alphalab.execution.quality import ( + ExecutionBenchmarks, + OrderExecution, + implementation_shortfall, +) +from alphalab.execution.simulator import ExecutionSimulator +from alphalab.instrument.record import InstrumentRecord +from alphalab.instrument.registry import InstrumentRegistry, register_instruments +from alphalab.lifecycle import digest_run +from alphalab.market.bar import Bar, TimeFrame +from alphalab.persistence import deserialize, serialize +from alphalab.portfolio.account import Account +from alphalab.risk.limits import ( + DailyLossLimit, + DrawdownLimit, + ExposureLimit, + LeverageLimit, + MarginLimit, + OrderSizeLimit, + PositionLimit, + RiskLimits, +) +from alphalab.runtime.execution_pipeline import ExecutionPipelineConfig +from alphalab.runtime.run_snapshot import RunObjects +from alphalab.runtime.snapshot import RuntimeObjects +from alphalab.strategy import DiscardingLogger, FixedClock, context_factory +from alphalab.strategy.context import StrategyContext +from alphalab.strategy.events import Intent +from alphalab.strategy.protocol import BaseStrategy +from alphalab.strategy.runtime import create_runtime, register_strategy +from alphalab.strategy.state import RuntimeState +from alphalab.strategy.supervisor import RuntimeSupervisor + +# --------------------------------------------------------------------------- # +# 1. The market: one hour of minute bars, a U-shaped day +# --------------------------------------------------------------------------- # + +STOCK = InstrumentRecord("WIDGET", AssetType.EQUITY, "XNAS", "USD") +REGISTRY: InstrumentRegistry = register_instruments(InstrumentRegistry(), (STOCK,)) +OPEN = 1_709_562_600.0 # 2024-03-04 09:30 New York +MINUTE = 60.0 +MINUTES = 60 +CENT = Decimal("0.01") + + +def at(minute: int) -> float: + return OPEN + minute * MINUTE + + +def price(minute: int) -> Decimal: + """A drifting, wavering price: up 30 cents over the hour.""" + + level = 50 + 0.005 * minute + 0.06 * math.sin(minute / 4) + return Decimal(str(level)).quantize(CENT, rounding=ROUND_HALF_EVEN) + + +def volume(minute: int) -> Decimal: + """Heavy at the open, light at midday: 4,000 shares falling to 1,500.""" + + return Decimal(1500 + int(2500 * ((minute - MINUTES) / MINUTES) ** 2)) + + +def dataset() -> MarketDataset: + bars = [] + for minute in range(MINUTES): + close = price(minute) + bars.append( + Bar( + STOCK.asset_id, + at(minute), + close, + close + Decimal("0.03"), + close - Decimal("0.03"), + close, + volume(minute), + None, + None, + TimeFrame.M1, + ) + ) + return MarketDataset.of("EX74-MINUTES", bars) + + +# --------------------------------------------------------------------------- # +# 2. The decision, and the execution choices +# --------------------------------------------------------------------------- # + +STRATEGY_ID = "EX74-BUYER" +QUANTITY = Decimal("3000") + + +class Buyer(BaseStrategy): + """Buys 3,000 shares at the open. What it owns is decided; how is the plan's.""" + + def on_bar(self, context: StrategyContext, event: Any) -> Iterable[Intent]: + if event.bar.timestamp != OPEN: + return () + return (Intent(STRATEGY_ID, STOCK.asset_id, QUANTITY, timestamp=OPEN),) + + +def running() -> RuntimeState: + state = register_strategy(create_runtime(), STRATEGY_ID, Buyer()) + strategy = state.strategies[STRATEGY_ID] + strategy, _ = RuntimeSupervisor.configure(strategy, {}, 1.0) + strategy, _ = RuntimeSupervisor.initialize(strategy, 1.1) + strategy, _ = RuntimeSupervisor.subscribe(strategy, frozenset({"bars"}), 1.2) + strategy, _ = RuntimeSupervisor.start(strategy, 1.3) + return replace(state, strategies={STRATEGY_ID: strategy}) + + +WINDOW = 30 * MINUTE + + +def profile() -> VolumeProfile: + """The volume the desk *expects*, six five-minute buckets: a stated shape.""" + + weights = (6, 5, 4, 3, 3, 3) + return VolumeProfile( + tuple(IntervalVolume(at(5 * n), at(5 * n + 5), Decimal(w)) for n, w in enumerate(weights)), + "desk's U-shape for the first half hour (stated, not estimated)", + ) + + +CHOICES: dict[str, ExecutionAlgorithm | None] = { + "whole": None, + "twap": TWAP(6, Urgency.neutral()), + "vwap": VWAP(profile(), MissingVolumePolicy.REFUSE, Urgency.neutral()), + "participation": Participation(Decimal("0.10"), None, None, IncompletePolicy.LEAVE_UNFILLED), + "iceberg": Iceberg(Decimal("400")), +} + + +def plan(algorithm: ExecutionAlgorithm | None) -> ExecutionPlan | None: + if algorithm is None: + return None + return ExecutionPlan(AlgorithmAssignment(algorithm, WINDOW, Decimal("1"))) + + +# --------------------------------------------------------------------------- # +# 3. The run: a liquidity cap and a stated cost model +# --------------------------------------------------------------------------- # + +#: Every cost stated: a cent of half-spread, square-root impact on the share of +#: the bar's volume a fill takes, and a dollar per fill. +COSTS = ExecutionCostModel( + spread_model=FixedHalfSpread(CENT), + slippage_model=NoSlippage(), + impact_model=SquareRootImpact(Decimal("0.01")), + commission_model=FixedCommission(Decimal("1.00")), + fee_model=NoFee(), + tax_model=NoTax(), +) +START_CASH = Decimal("1000000") +SEED = 74 +CONTEXTS = context_factory(FixedClock(0.0), DiscardingLogger()) + + +def run_config() -> RunConfig: + huge = Decimal("100000000") + return RunConfig( + mode=ExecutionMode.BACKTEST, + pipeline=ExecutionPipelineConfig( + account=Account("ACC-EX74", "USD", "Example 74", 1.0), + starting_cash=START_CASH, + budget=CapitalBudget( + global_capital=START_CASH, + maximum_exposure=START_CASH, + cash_buffer=Decimal("0"), + strategy_budgets={STRATEGY_ID: START_CASH}, + ), + allocation_constraints=AllocationConstraints( + allow_shorting=False, enforce_integer_quantities=True + ), + risk_limits=RiskLimits( + order_size=OrderSizeLimit(Decimal("10000"), huge), + position=PositionLimit(huge, huge), + exposure=ExposureLimit(huge, huge), + leverage=LeverageLimit(Decimal("2")), + margin=MarginLimit(Decimal("1")), + daily_loss=DailyLossLimit(huge, "America/New_York"), + drawdown=DrawdownLimit(Decimal("1")), + ), + simulator=ExecutionSimulator(cost_model=COSTS), + instruments=REGISTRY, + # A child is never filled at the bar it was released at. + fill_timing=FillTiming.NEXT_EVENT, + ), + # No fill -- whole or child -- takes more than a quarter of its bar. + fill_policy=LiquidityCappedFill(participation_rate=Decimal("0.25")), + seed=SEED, + start_timestamp=1.0, + ) + + +def run(name: str) -> BacktestResult: + return BacktestEngine.run( + run_config(), dataset(), running(), CONTEXTS, execution=plan(CHOICES[name]) + ) + + +# --------------------------------------------------------------------------- # +# 4. Measuring each run +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True, slots=True) +class Measured: + name: str + filled: Decimal + fills: int + status: OrderStatus + average: Decimal | None + commission: Decimal + shortfall_bps: Decimal | None + + +def benchmarks() -> ExecutionBenchmarks: + window = range(1, 31) + traded = sum((volume(m) for m in window), Decimal("0")) + vwap = sum((price(m) * volume(m) for m in window), Decimal("0")) / traded + return ExecutionBenchmarks( + decision_price=price(0), + arrival_price=price(0), + interval_vwap=vwap, + end_price=price(30), + source="example 74's synthetic bars: the open's close, the window's VWAP and close", + ) + + +def measure(name: str, result: BacktestResult) -> Measured: + (parent,) = result.state.oms.orders.orders() + order_id = str(parent.order_id.value) + reports = tuple(r for r in result.state.execution.history if r.order_id == order_id) + request = OrderRequest( + order_id=order_id, + strategy_id=STRATEGY_ID, + asset_id=parent.asset_id, + side=parent.side, + quantity=parent.quantity, + price=Decimal(parent.metadata["reference_price"]), + timestamp=parent.created_at, + contributions=(StrategyContribution(STRATEGY_ID, parent.quantity),), + ) + shortfall = implementation_shortfall( + OrderExecution( + order=request, + fills=reports, + currency="USD", + outcome=parent.status, + benchmarks=benchmarks(), + limit_price=None, + timeline=None, + fill_midpoints=None, + account_id="ACC-EX74", + ) + ) + return Measured( + name, + parent.filled_quantity, + len(reports), + parent.status, + shortfall.average_fill_price, + sum((r.commission for r in reports), Decimal("0")), + shortfall.total_bps, + ) + + +# --------------------------------------------------------------------------- # +# 5. The replay, paused with a child working, resumed from its payload +# --------------------------------------------------------------------------- # + + +def replayed(name: str, pause: int | None = None) -> BacktestResult: + execution = plan(CHOICES[name]) + if pause is None: + return ReplayBacktest.run( + run_config(), dataset(), running(), CONTEXTS, execution=execution + ).backtest + paused = ReplayBacktest.run( + run_config(), + dataset(), + running(), + CONTEXTS, + pause_when=lambda progress: progress.records_replayed == pause, + execution=execution, + ) + assert isinstance(paused, PausedReplay) + config = run_config() + objects = RunObjects( + pipeline=RuntimeObjects( + sizing_model=config.pipeline.sizing_model, + simulator=config.pipeline.simulator, + strategies={STRATEGY_ID: Buyer()}, + instruments=REGISTRY, + ), + fill_policy=config.fill_policy, + ) + payload = serialize(capture(paused)) + restored = restore( + from_primitives(deserialize(payload)), objects, dataset(), RunSchedule(), execution + ) + return ReplayBacktest.resume(restored, CONTEXTS).backtest + + +def number(value: Decimal | None, places: int = 2) -> str: + if value is None: + return "n/a" + return f"{value.quantize(Decimal(1).scaleb(-places), rounding=ROUND_HALF_EVEN):,}" + + +def main() -> None: + print("Example 74: one decision, five ways to execute it, measured on the same bars") + print(f" buy {QUANTITY:,} {STOCK.symbol} at the open; window 30 minutes; fills capped at 25%") + print() + print( + f" {'execution':<14}{'filled':>8}{'fills':>7}{'status':>11}{'avg px':>9}" + f"{'comm':>8}{'shortfall':>12}" + ) + results = {name: run(name) for name in CHOICES} + for name, result in results.items(): + m = measure(name, result) + print( + f" {m.name:<14}{number(m.filled, 0):>8}{m.fills:>7}{m.status.value:>11}" + f"{number(m.average):>9}{number(m.commission):>8}{number(m.shortfall_bps, 1):>9} bps" + ) + print() + + twap = results["twap"] + assert isinstance(twap, AlgorithmicResult) + book = twap.algorithms + (parent,) = twap.state.oms.orders.orders() + worked = book.algorithm_for(str(parent.order_id.value)) + assert worked is not None + print(" TWAP's children, each settled on the one parent order:") + for progress in worked.children.values(): + child = progress.child + print( + f" #{child.sequence} released {int((child.released_at - OPEN) / 60):>2}m " + f"{number(child.quantity, 0):>5} asked {number(progress.filled, 0):>5} filled " + f"{'working' if progress.outcome is None else progress.outcome.value}" + ) + held = str(parent.order_id.value) in twap.state.allocation.reservations + print(f" the parent's capital reservation, released when it filled: held={held}") + print() + + # A replay records its own mode, so it is compared by what it did. + replay = replayed("twap") + assert isinstance(replay, AlgorithmicResult) + assert capture_book(replay.algorithms) == capture_book(book) + assert replay.fills == twap.fills and replay.valuation == twap.valuation + resumed = replayed("twap", pause=7) + assert digest_run(resumed).result_id == digest_run(replay).result_id + rerun = run("twap") + assert digest_run(rerun).result_id == digest_run(twap).result_id + print(" replay: the same children, fills and valuation as the backtest") + print(" replay paused at bar 7 with a child working, resumed from JSON: the same replay") + print(f" rerun: the same result, {digest_run(twap).result_id[:16]}...") + + +if __name__ == "__main__": + main() diff --git a/examples/75_exercise_assign_and_deliver.py b/examples/75_exercise_assign_and_deliver.py new file mode 100644 index 00000000..73fafe59 --- /dev/null +++ b/examples/75_exercise_assign_and_deliver.py @@ -0,0 +1,457 @@ +""" +AlphaLab Examples +================= + +Example 75 : Exercise, Assign and Deliver -- an Option's Whole Life in a Backtest + +Difficulty : Advanced + +Estimated Time : 15 minutes + +Prerequisites +------------- + +✓ Example 70 (build a strategy) +✓ Example 35 (option pricing) +✓ Example 72 (a run's schedule) + +Topics +------ + +• A market declared, not assumed: its time zone, sessions and holidays, and every + instant of the contracts' lives -- expiry at its close, delivery T+1 by its + calendar -- read from it +• An American, physically settled call: exercised early, before a dividend, by a + policy the researcher states and that reads only prices already published +• A European, cash-settled put on an index: settled at expiry against the + index's published value +• The deliverable moving against the strike through the portfolio's one fill + authority; the dividend then paid on the delivered shares +• Contract terms AlphaLab cannot honour refused at declaration or at the event +• The same run replayed, paused before the exercise, written to JSON and resumed + +What this shows +--------------- + +v4.1.1's evidence for ledger FUT-013: a derivative's lifecycle is part of the +run, not the caller's arithmetic. The contract declares what it does +(:class:`~alphalab.instrument.terms.OptionTerms`); the run's schedule carries the +exercise and the expiry; the engine moves the cash, the contracts and the shares, +and attributes them. The prices are **synthetic** and the market's calendar is a +stated one -- nothing here describes a real exchange. +``tests/integration/test_lifecycle_example.py`` holds it to its figures. + +Run + + python examples/75_exercise_assign_and_deliver.py +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import replace +from datetime import date, time +from decimal import Decimal +from typing import Any + +from alphalab.allocation.budget import CapitalBudget +from alphalab.allocation.constraints import AllocationConstraints +from alphalab.backtesting import ( + BacktestEngine, + BacktestResult, + ExecutionMode, + MarketDataset, + PausedReplay, + ReplayBacktest, + RunConfig, + RunSchedule, + ScheduledDistribution, + ScheduledExercise, + ScheduledExpiry, +) +from alphalab.backtesting.replay_snapshot import capture, from_primitives, restore +from alphalab.conventions.economics import InstrumentEconomics, SettlementModel +from alphalab.conventions.lot import LotSpecification +from alphalab.conventions.settlement import SettlementBasis, SettlementRule +from alphalab.core.enums import AssetType +from alphalab.data.calendar import MarketCalendar, SessionWindow +from alphalab.execution.policy import FillTiming +from alphalab.instrument.exceptions import InstrumentInputError +from alphalab.instrument.record import InstrumentRecord +from alphalab.instrument.registry import InstrumentRegistry, register_instruments +from alphalab.instrument.terms import AutomaticExercise, Deliverable, OptionTerms +from alphalab.lifecycle import digest_run +from alphalab.market.bar import Bar, TimeFrame +from alphalab.options.enums import ExerciseStyle, OptionType +from alphalab.options.expiration import SettlementStyle +from alphalab.persistence import deserialize, serialize +from alphalab.portfolio.account import Account +from alphalab.portfolio.corporate_actions import Exercise, OptionExpiry +from alphalab.risk.limits import ( + DailyLossLimit, + DrawdownLimit, + ExposureLimit, + LeverageLimit, + MarginLimit, + OrderSizeLimit, + PositionLimit, + RiskLimits, +) +from alphalab.runtime.calendars import session_close, settles_at +from alphalab.runtime.exceptions import RuntimeValidationError +from alphalab.runtime.execution_pipeline import ExecutionPipelineConfig +from alphalab.runtime.run_snapshot import RunObjects +from alphalab.runtime.snapshot import RuntimeObjects +from alphalab.strategy import DiscardingLogger, FixedClock, context_factory +from alphalab.strategy.context import StrategyContext +from alphalab.strategy.events import Intent, IntentKind +from alphalab.strategy.protocol import BaseStrategy +from alphalab.strategy.runtime import create_runtime, register_strategy +from alphalab.strategy.state import RuntimeState +from alphalab.strategy.supervisor import RuntimeSupervisor + +# --------------------------------------------------------------------------- # +# 1. A market, declared: its zone, sessions and a holiday +# --------------------------------------------------------------------------- # + +MARKET = MarketCalendar( + "EX75-MARKET", + "Europe/London", + {day: (SessionWindow(time(8, 0), time(16, 30)),) for day in range(5)}, + frozenset({date(2024, 5, 27)}), # a Monday the market is shut +) +CURRENCY = "GBP" +T_PLUS_ONE = SettlementRule(SettlementBasis.TRADING_DAYS, 1) +SAME_DAY = SettlementRule(SettlementBasis.TRADE_DATE, 0) +SESSIONS = MARKET.sessions_between(date(2024, 5, 13), date(2024, 6, 14)) +EXPIRY_DAY = date(2024, 6, 14) +EXPIRES = session_close(MARKET, EXPIRY_DAY) +EX_DIVIDEND = date(2024, 6, 4) +DIVIDEND = Decimal("3.00") + +ONE = LotSpecification(Decimal("1"), Decimal("1")) +STOCK = InstrumentRecord("WIDGET", AssetType.EQUITY, "XEXM", CURRENCY) +INDEX = InstrumentRecord("WIDX", AssetType.INDEX, "XEXM", CURRENCY) +CALL = InstrumentRecord( + "WIDGETC100", + AssetType.OPTION, + "XEXM", + CURRENCY, + economics=InstrumentEconomics(Decimal("100"), SettlementModel.OPTION_PREMIUM, lot=ONE), + last_trade_at=EXPIRES, + terms=OptionTerms( + option_type=OptionType.CALL, + strike=Decimal("100"), + exercise_style=ExerciseStyle.AMERICAN, + settlement=SettlementStyle.PHYSICAL, + deliverable=Deliverable(STOCK.asset_id, Decimal("100")), + expires_at=EXPIRES, + automatic_exercise=AutomaticExercise(Decimal("0.01")), + ), +) +PUT = InstrumentRecord( + "WIDXP2000", + AssetType.OPTION, + "XEXM", + CURRENCY, + economics=InstrumentEconomics(Decimal("10"), SettlementModel.OPTION_PREMIUM, lot=ONE), + last_trade_at=EXPIRES, + terms=OptionTerms( + option_type=OptionType.PUT, + strike=Decimal("2000"), + exercise_style=ExerciseStyle.EUROPEAN, + settlement=SettlementStyle.CASH, + deliverable=Deliverable(INDEX.asset_id, Decimal("10")), + expires_at=EXPIRES, + automatic_exercise=AutomaticExercise(Decimal("0")), + ), +) +REGISTRY: InstrumentRegistry = register_instruments(InstrumentRegistry(), (STOCK, INDEX, CALL, PUT)) + + +# --------------------------------------------------------------------------- # +# 2. Synthetic prices, one bar per session at its close +# --------------------------------------------------------------------------- # + + +def stock_close(n: int) -> Decimal: + return Decimal("98") + Decimal(n) * Decimal("0.75") + + +def index_close(n: int) -> Decimal: + return Decimal("2050") - Decimal(n) * Decimal("4") + + +def call_close(n: int) -> Decimal: + return max(stock_close(n) - Decimal("100"), Decimal("0")) + Decimal("2") + + +def put_close(n: int) -> Decimal: + return max(Decimal("2000") - index_close(n), Decimal("0")) + Decimal("15") + + +def _bar(record: InstrumentRecord, n: int, price: Decimal) -> Bar: + at = session_close(MARKET, SESSIONS[n]) + return Bar( + record.asset_id, at, price, price, price, price, Decimal("10000"), None, None, TimeFrame.D1 + ) + + +def dataset() -> MarketDataset: + bars: list[Bar] = [] + for n, day in enumerate(SESSIONS): + bars.append(_bar(STOCK, n, stock_close(n))) + bars.append(_bar(INDEX, n, index_close(n))) + if day < EXPIRY_DAY: + bars.append(_bar(CALL, n, call_close(n))) + bars.append(_bar(PUT, n, put_close(n))) + # One stock bar after expiry, so the expiry falls inside the data. + after = session_close(MARKET, MARKET.add_trading_days(EXPIRY_DAY, 1)) + bars.append( + Bar( + STOCK.asset_id, + after, + stock_close(len(SESSIONS)), + stock_close(len(SESSIONS)), + stock_close(len(SESSIONS)), + stock_close(len(SESSIONS)), + Decimal("10000"), + None, + None, + TimeFrame.D1, + ) + ) + return MarketDataset.of("EX75-BARS", sorted(bars, key=lambda b: (b.timestamp, b.asset_id))) + + +# --------------------------------------------------------------------------- # +# 3. A researcher's strategy, and a stated exercise policy +# --------------------------------------------------------------------------- # + +STRATEGY_ID = "EX75-HOLDER" + + +class Holder(BaseStrategy): + """Buys two calls when three-session momentum first turns up, and one index put. + + Decides from the history its context shows and nothing it remembers, so a + rebuilt instance continues a stored run exactly. + """ + + def on_bar(self, context: StrategyContext, event: Any) -> Iterable[Intent]: + bar = event.bar + if bar.asset_id != STOCK.asset_id: + return () + closes = [seen.close for seen in context.history.bars(STOCK.asset_id, 5)] + if len(closes) != 4 or closes[-1] <= closes[0]: + return () + return ( + Intent( + STRATEGY_ID, + CALL.asset_id, + Decimal("2"), + timestamp=bar.timestamp, + kind=IntentKind.TARGET_QUANTITY, + ), + Intent( + STRATEGY_ID, + PUT.asset_id, + Decimal("1"), + timestamp=bar.timestamp, + kind=IntentKind.TARGET_QUANTITY, + ), + ) + + +def early_exercise() -> ScheduledExercise: + """Exercise one call at the close before the ex-dividend date, when the dividend is worth more. + + The rule of thumb, stated: a call deep enough in the money that its holder + would rather own the shares on the ex-date. It reads the stock's close on + the decision day only -- published at the instant the exercise takes effect -- + and the dividend, declared in advance. Whether it is wise is the + researcher's question; the engine checks only that the contract allows it. + """ + + decision_day = SESSIONS[SESSIONS.index(EX_DIVIDEND) - 1] + close = stock_close(SESSIONS.index(decision_day)) + assert close - Decimal("100") > DIVIDEND, "the stated policy would not exercise" + return ScheduledExercise( + Exercise(CALL.asset_id, Decimal("1"), reference="pre-dividend"), + session_close(MARKET, decision_day), + ) + + +def schedule() -> RunSchedule: + return RunSchedule.of( + exercises=[early_exercise()], + # Paid to whoever holds the shares at the ex-date's open. + distributions=[ + ScheduledDistribution( + STOCK.asset_id, DIVIDEND, CURRENCY, settles_at(MARKET, SAME_DAY, EX_DIVIDEND) + ) + ], + expiries=[ + ScheduledExpiry(OptionExpiry(CALL.asset_id, stock_close(len(SESSIONS) - 1)), EXPIRES), + ScheduledExpiry(OptionExpiry(PUT.asset_id, index_close(len(SESSIONS) - 1)), EXPIRES), + ], + ) + + +def running() -> RuntimeState: + state = register_strategy(create_runtime(), STRATEGY_ID, Holder()) + entry = state.strategies[STRATEGY_ID] + entry, _ = RuntimeSupervisor.configure(entry, {}, 1.0) + entry, _ = RuntimeSupervisor.initialize(entry, 1.1) + entry, _ = RuntimeSupervisor.subscribe(entry, frozenset({"bars"}), 1.2) + entry, _ = RuntimeSupervisor.start(entry, 1.3) + return replace(state, strategies={STRATEGY_ID: entry}) + + +START_CASH = Decimal("100000") +CONTEXTS = context_factory(FixedClock(0.0), DiscardingLogger()) + + +def run_config() -> RunConfig: + huge = Decimal("100000000") + return RunConfig( + mode=ExecutionMode.BACKTEST, + pipeline=ExecutionPipelineConfig( + account=Account("ACC-EX75", CURRENCY, "Example 75", 1.0), + starting_cash=START_CASH, + budget=CapitalBudget(START_CASH, START_CASH, Decimal("0"), {STRATEGY_ID: START_CASH}), + allocation_constraints=AllocationConstraints( + allow_shorting=False, enforce_integer_quantities=True + ), + risk_limits=RiskLimits( + order_size=OrderSizeLimit(huge, huge), + position=PositionLimit(huge, huge), + exposure=ExposureLimit(huge, huge), + leverage=LeverageLimit(Decimal("10")), + margin=MarginLimit(Decimal("1")), + daily_loss=DailyLossLimit(huge, "Europe/London"), + drawdown=DrawdownLimit(Decimal("1")), + ), + instruments=REGISTRY, + fill_timing=FillTiming.NEXT_EVENT, + ), + seed=75, + start_timestamp=1.0, + ) + + +def run() -> BacktestResult: + return BacktestEngine.run(run_config(), dataset(), running(), CONTEXTS, schedule=schedule()) + + +def replayed(pause: int | None = None) -> BacktestResult: + if pause is None: + return ReplayBacktest.run( + run_config(), dataset(), running(), CONTEXTS, schedule=schedule() + ).backtest + paused = ReplayBacktest.run( + run_config(), + dataset(), + running(), + CONTEXTS, + schedule=schedule(), + pause_when=lambda progress: progress.records_replayed == pause, + ) + assert isinstance(paused, PausedReplay) + config = run_config() + objects = RunObjects( + pipeline=RuntimeObjects( + sizing_model=config.pipeline.sizing_model, + simulator=config.pipeline.simulator, + strategies={STRATEGY_ID: Holder()}, + instruments=REGISTRY, + ), + fill_policy=config.fill_policy, + ) + restored = restore( + from_primitives(deserialize(serialize(capture(paused)))), objects, dataset(), schedule() + ) + return ReplayBacktest.resume(restored, CONTEXTS).backtest + + +# --------------------------------------------------------------------------- # +# 4. What is refused +# --------------------------------------------------------------------------- # + + +def refusals() -> list[str]: + found = [] + try: + terms = CALL.terms + assert isinstance(terms, OptionTerms) + replace(terms, exercise_style="BERMUDAN") # type: ignore[arg-type] + except InstrumentInputError as exc: + found.append(f"a Bermudan call: {exc}") + try: + replace(CALL, last_trade_at=EXPIRES + 3600) + except InstrumentInputError as exc: + found.append(f"exercise ending before trading: {exc}") + early_put = RunSchedule.of( + exercises=[ + ScheduledExercise( + Exercise(PUT.asset_id, Decimal("1"), Decimal("1990")), + session_close(MARKET, date(2024, 6, 3)), + ) + ], + ) + try: + BacktestEngine.run(run_config(), dataset(), running(), CONTEXTS, schedule=early_put) + except RuntimeValidationError as exc: + found.append(f"a European put exercised early: {exc}") + return found + + +def money(value: Decimal) -> str: + return f"{value.quantize(Decimal('0.01')):,}" + + +def main() -> None: + print("Example 75: an option's whole life in a backtest") + print( + f" market {MARKET.calendar_id} ({MARKET.timezone_name}); expiry at its close on " + f"{EXPIRY_DAY.isoformat()}, a holiday on 2024-05-27" + ) + result = run() + portfolio = result.state.portfolio + print() + print(" lifecycle records:") + for record in result.state.trade_records: + if record.trade_id.startswith(("EXERCISE", "DELIVERY", "EXPIRY")): + print( + f" {record.trade_id.split('-')[0]:<9} " + f"{REGISTRY.instruments[record.asset_id].symbol:<11} " + f"realized {money(record.realized_pnl):>10}" + ) + stock = portfolio.positions[STOCK.asset_id] + print() + print( + f" WIDGET held: {stock.quantity} (exercised early and at expiry), " + f"cost {money(stock.average_cost)}" + ) + print( + f" cash: {money(portfolio.cash.balance(CURRENCY))} {CURRENCY}; " + f"options held: {[a for a in (CALL.asset_id, PUT.asset_id) if a in portfolio.positions]}" + ) + valuation = result.valuation + print(f" equity {money(valuation.equity)}; realized {money(valuation.realized_pnl)}") + print() + for line in refusals(): + print(f" refused -- {line[:110]}") + print() + replay = replayed() + assert replay.fills == result.fills and replay.valuation == result.valuation + resumed = replayed(pause=40) + assert digest_run(resumed).result_id == digest_run(replay).result_id + assert digest_run(run()).result_id == digest_run(result).result_id + print(" replay: the same fills and valuation; paused before the exercise and resumed") + identity = digest_run(result).result_id + print(f" from JSON: the same replay; rerun: the same result {identity[:16]}...") + + +if __name__ == "__main__": + main() diff --git a/examples/README.md b/examples/README.md index 11e8cd4e..780e2442 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,7 +8,7 @@ python examples/01_research.py ``` They are **not** part of the automated test suite — the suite covers the same -paths far more thoroughly under `tests/` — but all seventy-one are executed as a +paths far more thoroughly under `tests/` — but all seventy-five are executed as a release gate, and a change that breaks one is a change that breaks a documented API. @@ -89,6 +89,10 @@ API. | 69 | `69_cron_timers.py` | Cron expressions on a stated zone's wall clock: the day-field rule, a spring-forward gap and a fall-back repeat with their UTC instants, refusals when written, and a cron timer fired and rescheduled on the engine | | 70 | `70_build_a_strategy.py` | **Build your first strategy** (v4.0): labelled synthetic closes ingested under a refusing policy, a moving-average crossover written against the strategy protocol and started with `start_strategy`, every fill and the cash checked against arithmetic done by hand, a flat series that never trades, a run stopped halfway and continued to a byte-identical result, and five refusals | | 71 | `71_pause_and_resume_a_replay.py` | **Pause a replay, keep it, resume it** (v4.1): a replay paused between two records, captured and kept in a file store, restored with freshly built objects as a new process would, and resumed to the same `result_id` as the uninterrupted replay; paused every five records through a payload each time, to the same result; another dataset, another commission and bytes altered in the store each refused | +| 72 | `72_compose_a_multi_asset_strategy.py` | **Compose a multi-asset strategy** (v4.1.1 acceptance scenario): an equity and an index future from two vendor files read as one combined dataset; social-media posts and a CPI series with a revision, each delivered when it became available -- a post collected two weeks late, a revision, a release after the data, an undated post, a repeated and an unreadable row; a researcher-written strategy combining momentum, sentiment and inflation into a position and a futures hedge, at the next open, with costs and a refusing risk gate; its manifest; the same run replayed, paused and resumed, and stored and continued; and a counterfactual showing nothing moves before the late values were knowable | +| 73 | `73_train_and_trade_a_model.py` | **Train a model on the past and trade it forward** (v4.1.1): two features defined once and computed by the study path; a linear model fitted only on samples whose label was knowable before a boundary; the same definitions evaluated inside the strategy, equal to the study's at every instant; the fitted coefficients as the strategy's declared parameters and state; trading from the boundary on; a counterfactual on every later price that changes neither the model nor an earlier decision | +| 74 | `74_evaluate_an_execution_algorithm.py` | **Evaluate an execution algorithm in a backtest** (v4.1.1): one decision executed whole and by TWAP, VWAP, participation and iceberg over the same minute bars; every child through the run's liquidity cap, spread, impact and commission, settled on one parent order; implementation shortfall for each; the replay paused with a child working and resumed from JSON | +| 75 | `75_exercise_assign_and_deliver.py` | **Exercise, assign and deliver** (v4.1.1): an option's whole life on a declared market's calendar -- an American physical call exercised early before a dividend, a European cash-settled index put settled at expiry, the shares delivered against the strike, refusals, and the run replayed and resumed | ## Reading order diff --git a/nowandfuture.md b/nowandfuture.md index d8709950..c8e9caf8 100644 --- a/nowandfuture.md +++ b/nowandfuture.md @@ -821,12 +821,12 @@ all. (v3.10 removed `feed` and `live`; v3.11 `studio`, `workbench` and | `instrument` | Canonical instrument identity, the registry, classification and its provenance | | `common` | Version, `BaseEvent`, deterministic serialization, the seeded identifier source, `AppendOnlyLog` / `PersistentMap` / `PersistentSet`, TLS policy, and the point-in-time core: `known_as_of`, and since v3.7 `PointInTimeStamp`, `AvailabilityBasis`, `VisibilityRule` and `PointInTimeIndex`. Since v3.9 the currency-conversion protocols (`common.currency`), moved down from `allocation.capital`. Since v3.10 the one version declaration (`common._version`), each currency's minor unit (`common.currency_units`) and the pinned accounting context (`common.arithmetic`) | | `persistence` | The codec spine (`serialize`, typed `decode`, exceptions), `RunStateStore`, and since v3.10 versioned schema upgrades (`persistence.upgrade`) | -| `backtesting` | The dataset type and the two drivers over it. Since v3.12 a backtest merges point-in-time observations (`alt_data`) with its records | +| `backtesting` | The dataset type and the two drivers over it. Since v3.12 a backtest merges point-in-time observations (`alt_data`) with its records; since the v4.1.1 candidate a replay does too, by the same public functions (OFE-027) | | `replay` | The deterministic replay cursor, clock and session lifecycle | | `broker` | **One** venue: `BrokerProtocol`, the canonical broker vocabulary, reconciliation, `PaperBroker` (the HMAC transport and `RestVenueBroker` left in v3.11 with their credentials, BRK-007). Since v3.9 also normalized venue events and their application to the mirror (`broker.lifecycle`), cancel and amend request identities (`broker.requests`) and snapshot reconciliation; it re-exports the capability model | | `data` | The canonical **wire** record, and the Universal Data Engine: source provenance, delimited reading, schema detection, timestamps and frequency, validation findings, cleaning policy, quality reporting, asset-class semantics, market calendars, corporate-action basis, and the derived dataset version. Its only outward edges are `common` and `options` (one leaf enum), which is what keeps the package graph acyclic | | `marketdata` | The HTTP and WebSocket transports and the wire records it re-exports from `data.feed` (`Timeframe` was removed in v3.11: the interval is `market.bar.TimeFrame`). Until v3.10 also vendor clients (four of five were stubs) and a v1 provider engine, removed: a provider is the host application's | -| `api` | **The top of the graph** (v3.1). The application-facing Python API joining the data layer to the execution path: `ingest_csv`, `select`, `to_market_dataset`, `backtest`, `replay`. Since v3.7 also point-in-time ingestion of observations, events and fundamentals with an explicit availability rule, and the lifting of single-timestamp wire records. Since v3.8 sector and currency classifications read from the instrument registry. Nothing imports it, which is what lets it depend on both `data` and `market` without closing a cycle | +| `api` | **The top of the graph** (v3.1). The application-facing Python API joining the data layer to the execution path: `ingest_csv`, `select`, `to_market_dataset`, `backtest`, `replay`. Since v3.7 also point-in-time ingestion of observations, events and fundamentals with an explicit availability rule, and the lifting of single-timestamp wire records. Since v3.8 sector and currency classifications read from the instrument registry. Since the v4.1.1 candidate several datasets read by one run under one derived identity (`combine_datasets`, OFE-028), and `observations` on `backtest` and `replay`. Nothing imports it, which is what lets it depend on both `data` and `market` without closing a cycle | ## The lifecycle path @@ -1144,11 +1144,11 @@ constant and **one** typed decoder. | `AllocationState` | `allocation.snapshot` | `ALLOCATION_SNAPSHOT_SCHEMA` | 3 | | `ExecutionPipelineState` | `runtime.snapshot` | `PIPELINE_SNAPSHOT_SCHEMA` | 8 | | `RunState` | `runtime.run_snapshot` | `RUN_SNAPSHOT_SCHEMA` | 5 | -| `InstrumentRegistry` | `instrument.snapshot` | `INSTRUMENT_SNAPSHOT_SCHEMA` | 3 | +| `InstrumentRegistry` | `instrument.snapshot` | `INSTRUMENT_SNAPSHOT_SCHEMA` | 4 | | `BrokerState` | `broker.snapshot` | `BROKER_SNAPSHOT_SCHEMA` | 2 | | `LiveRunState` | `runtime.live_snapshot` | `LIVE_SNAPSHOT_SCHEMA` | 2 | | `FxFeedState` | `portfolio.fx_feed` | `FX_FEED_SNAPSHOT_SCHEMA` | 1 | -| `PausedReplay` | `backtesting.replay_snapshot` | `REPLAY_SNAPSHOT_SCHEMA` | 1 | +| `PausedReplay` | `backtesting.replay_snapshot` | `REPLAY_SNAPSHOT_SCHEMA` | 2 | Plus `persistence.run_state.RUN_STATE_ENVELOPE_SCHEMA = 1`, which versions what the *store* records about a payload and nothing inside it, and the envelopes of diff --git a/tests/installed_smoke.py b/tests/installed_smoke.py index b63af3f8..7eb3d4b3 100644 --- a/tests/installed_smoke.py +++ b/tests/installed_smoke.py @@ -139,10 +139,22 @@ def main(expected_version: str) -> None: sys.addaudithook(_refuse_the_network) sys.path.insert(0, str(SOURCE_TREE / "examples")) # (v4.1) and the replay paused, kept in a file store, restored and resumed. + # (v4.1.1) and the multi-asset acceptance scenario: two datasets combined, + # sentiment and macro releases delivered at their availability, a hedge in + # a future, a manifest, a replay paused and resumed, a run stored and + # continued -- every name it imports, from the installed artifact; a + # model fitted before a boundary and traded after it; and one decision + # executed by four algorithms in children, measured and replayed; and an + # option's whole life -- exercised early, delivered, settled -- on a + # declared market's calendar. for name in ( "11_unified_backtest.py", "70_build_a_strategy.py", "71_pause_and_resume_a_replay.py", + "72_compose_a_multi_asset_strategy.py", + "73_train_and_trade_a_model.py", + "74_evaluate_an_execution_algorithm.py", + "75_exercise_assign_and_deliver.py", ): # Loaded as a module, registered first: a dataclass with postponed # annotations looks its module up in sys.modules while it is built. @@ -172,7 +184,7 @@ def main(expected_version: str) -> None: print( f"installed alphalab {installed} at {location.parent}: pip check clean, no runtime " f"requirement, {len(imported)} modules, {public} public names as the manifest records, " - "py.typed present, examples 11, 70 and 71 and the consumer contract ran with the " + "py.typed present, examples 11 and 70 to 75 and the consumer contract ran with the " "network refused" ) diff --git a/tests/integration/test_algorithms_on_the_historical_path.py b/tests/integration/test_algorithms_on_the_historical_path.py new file mode 100644 index 00000000..44277ff3 --- /dev/null +++ b/tests/integration/test_algorithms_on_the_historical_path.py @@ -0,0 +1,993 @@ +"""Execution algorithms work their children through the historical drivers (v4.1.1, FUT-009). + +Every test here runs a parent order through :meth:`BacktestEngine.run` or +:meth:`ReplayBacktest.run` with an :class:`ExecutionPlan`: the strategy's intent +becomes one OMS order, the algorithm releases children at the asset's bars, the +run's own fill policy and cost model fill each child, and the fill settles on the +parent. Nothing is asserted about an algorithm in isolation -- that is +``tests/unit/execution/test_execution_algorithms.py`` -- only about what the run +did with it. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from dataclasses import replace +from decimal import Decimal +from typing import Any + +import pytest + +from alphalab.allocation.events import AllocationReservationReleased +from alphalab.backtesting import ( + AlgorithmAssignment, + AlgorithmicResult, + BacktestEngine, + BacktestResult, + ExecutionPlan, + ExecutionPlanError, + MarketDataset, + PausedReplay, + ReplayBacktest, + RunSchedule, + ScheduledSplit, + VenueCheck, +) +from alphalab.backtesting.algorithmic import book_from_primitives, capture_book +from alphalab.backtesting.replay_snapshot import capture as capture_replay +from alphalab.backtesting.replay_snapshot import from_primitives as replay_from_primitives +from alphalab.backtesting.replay_snapshot import restore as restore_replay +from alphalab.broker import BrokerEngine, PaperBroker +from alphalab.broker.reconciliation import ExternalOrderMap +from alphalab.common.order_terms import MARKET, OrderTerms, TimeInForce +from alphalab.core.capabilities import ( + ANY_LISTING_VENUE, + AccountCapability, + Capability, + CapabilityDeclaration, + MarketCapability, + Support, +) +from alphalab.core.enums import AssetType, OrderStatus, OrderType +from alphalab.execution.algorithms import ( + TWAP, + VWAP, + AlgorithmStatus, + AlgorithmTerms, + Iceberg, + IncompletePolicy, + IntervalVolume, + MissingVolumePolicy, + Participation, + Slicing, + Urgency, + VolumeProfile, + record_child_execution, + release_children, + start_algorithm, +) +from alphalab.execution.commission import FixedCommission +from alphalab.execution.costs import ( + FREE, + ExecutionCostModel, + FixedHalfSpread, + NoFee, + NoImpact, + NoSlippage, + NoTax, +) +from alphalab.execution.fill import FillStatus +from alphalab.execution.policy import ( + FillPolicy, + FillTiming, + ImmediateFill, + LiquidityCappedFill, + StaticFill, +) +from alphalab.execution.simulator import ExecutionSimulator +from alphalab.lifecycle import digest_run +from alphalab.market.bar import Bar, TimeFrame +from alphalab.market.events import BarClosed +from alphalab.persistence import deserialize, serialize +from alphalab.persistence.exceptions import StateDecodeError +from alphalab.portfolio.corporate_actions import Split +from alphalab.runtime.broker_routing import ChildOrderBindings, RoutingConfig, route_child_order +from alphalab.runtime.exceptions import RuntimeValidationError +from alphalab.runtime.execution_pipeline import ExecutionPipeline, ExecutionRouting +from alphalab.runtime.run import ExecutionMode +from alphalab.runtime.run_snapshot import RunObjects +from alphalab.runtime.snapshot import RuntimeObjects +from alphalab.strategy.context import StrategyContext +from alphalab.strategy.events import Intent +from alphalab.strategy.protocol import BaseStrategy +from alphalab.strategy.runtime import create_runtime, register_strategy +from alphalab.strategy.state import RuntimeState as StrategyRuntimeState +from alphalab.strategy.supervisor import RuntimeSupervisor +from tests.integration.harness import ( + START_CASH, + backtest_config, + context_factory, + equity, + registry_of, + sized_quote, +) + +ACME_RECORD = equity("ACME") +ACME = ACME_RECORD.asset_id +REGISTRY = registry_of(ACME_RECORD) +T0 = 1_700_000_000.0 +MINUTE = 60.0 +PRICES = tuple(Decimal("100") + Decimal(n) / Decimal("4") for n in range(12)) +VOLUMES = (Decimal("400"),) * 12 +HALF_SPREAD = Decimal("0.05") +COMMISSION = Decimal("1.00") +COSTS = ExecutionSimulator( + cost_model=ExecutionCostModel( + spread_model=FixedHalfSpread(HALF_SPREAD), + slippage_model=NoSlippage(), + impact_model=NoImpact(), + commission_model=FixedCommission(COMMISSION), + fee_model=NoFee(), + tax_model=NoTax(), + ) +) +TWAP_4 = AlgorithmAssignment(TWAP(4, Urgency.neutral()), 8 * MINUTE, Decimal("1")) +TWAP_PLAN = ExecutionPlan(TWAP_4) + + +def at(minute: int) -> float: + return T0 + minute * MINUTE + + +class Orders(BaseStrategy): + """Asks for a scripted delta at chosen bars, on chosen terms.""" + + def __init__(self, strategy_id: str, script: Mapping[float, tuple[Decimal, OrderTerms]]): + self._id = strategy_id + self._script = dict(script) + + def on_bar(self, context: StrategyContext, event: Any) -> Iterable[Intent]: + planned = self._script.get(event.bar.timestamp) + if planned is None: + return () + quantity, terms = planned + return (Intent(self._id, ACME, quantity, terms=terms, timestamp=event.bar.timestamp),) + + +def strategies( + scripts: Mapping[str, Mapping[float, tuple[Decimal, OrderTerms]]], +) -> StrategyRuntimeState: + state = create_runtime() + for strategy_id, script in scripts.items(): + state = register_strategy(state, strategy_id, Orders(strategy_id, script)) + running = {} + for strategy_id, entry in state.strategies.items(): + configured, _ = RuntimeSupervisor.configure(entry, {}, 1.0) + initialized, _ = RuntimeSupervisor.initialize(configured, 1.1) + subscribed, _ = RuntimeSupervisor.subscribe(initialized, frozenset({"*"}), 1.2) + running[strategy_id], _ = RuntimeSupervisor.start(subscribed, 1.3) + return replace(state, strategies=running) + + +def bars(prices: Iterable[Decimal] = PRICES, volumes: Iterable[Decimal] = VOLUMES) -> MarketDataset: + return MarketDataset.of( + "ALGO-BARS", + [ + Bar( + ACME, + at(n), + p, + p + Decimal("0.5"), + p - Decimal("0.5"), + p, + v, + None, + None, + TimeFrame.M1, + ) + for n, (p, v) in enumerate(zip(prices, volumes, strict=True)) + ], + ) + + +def config( + fill_policy: FillPolicy | None = None, + timing: FillTiming = FillTiming.NEXT_EVENT, + simulator: ExecutionSimulator = COSTS, + strategy_ids: tuple[str, ...] = ("S",), +) -> Any: + base = backtest_config(strategy_ids[0], fill_policy=fill_policy, simulator=simulator) + budget = replace( + base.pipeline.budget, + strategy_budgets={sid: START_CASH / len(strategy_ids) for sid in strategy_ids}, + ) + return replace( + base, + pipeline=replace(base.pipeline, fill_timing=timing, instruments=REGISTRY, budget=budget), + ) + + +BUY_100 = {"S": {at(0): (Decimal("100"), MARKET)}} + + +def run( + plan: ExecutionPlan = TWAP_PLAN, + scripts: Mapping[str, Mapping[float, tuple[Decimal, OrderTerms]]] = BUY_100, + dataset: MarketDataset | None = None, + schedule: RunSchedule | None = None, + **settings: Any, +) -> AlgorithmicResult: + return BacktestEngine.run( + config(strategy_ids=tuple(scripts), **settings), + bars() if dataset is None else dataset, + strategies(scripts), + context_factory, + schedule=RunSchedule() if schedule is None else schedule, + execution=plan, + ) + + +def whole(**settings: Any) -> BacktestResult: + """The same run, given no plan: every order filled whole, as before v4.1.1.""" + + return BacktestEngine.run(config(**settings), bars(), strategies(BUY_100), context_factory) + + +def parent_of(result: BacktestResult) -> Any: + (parent,) = result.state.oms.orders.orders() + return parent + + +def parent_id(result: BacktestResult) -> str: + return str(parent_of(result).order_id.value) + + +# --------------------------------------------------------------------------- # +# 1-3. Quantities reconcile; children are derived, in order; nothing overfills +# --------------------------------------------------------------------------- # + + +def test_a_twap_parent_trades_as_four_children_that_reconcile_to_it() -> None: + result = run() + parent = parent_of(result) + book = result.algorithms + assert book is not None and not book.incomplete + algorithm = book.algorithm_for(parent_id(result)) + assert algorithm is not None and algorithm.status is AlgorithmStatus.COMPLETED + + # One OMS order, filled once in four pieces; the book, the OMS, the portfolio + # and the execution reports agree on every unit. + assert parent.status is OrderStatus.FILLED and parent.filled_quantity == Decimal("100") + children = [p.child for p in algorithm.children.values()] + assert [c.child_id for c in children] == [f"{parent_id(result)}/{n}" for n in (1, 2, 3, 4)] + assert [c.quantity for c in children] == [Decimal("25")] * 4 + assert [c.released_at for c in children] == [at(0), at(2), at(4), at(6)] + assert sum(f.quantity for f in book.fills) == algorithm.filled == parent.quantity + assert result.state.portfolio.positions[ACME].quantity == Decimal("100") + reports = [r for r in result.state.execution.history if r.order_id == parent_id(result)] + assert [r.execution_id for r in reports] == [f.execution_id for f in book.fills] + assert [r.status for r in reports] == [FillStatus.PARTIAL_FILL] * 3 + [FillStatus.FULL_FILL] + + +def test_a_child_trades_only_at_a_bar_after_its_release_and_inside_its_slice() -> None: + book = run().algorithms + assert book is not None + # NEXT_EVENT: released at the start of each two-minute slice, filled at the + # next bar -- never at the bar it was released at, never after its slice. + assert [f.timestamp for f in book.fills] == [at(1), at(3), at(5), at(7)] + for fill in book.fills: + algorithm = book.algorithm_for(fill.parent_order_id) + assert algorithm is not None + child = algorithm.children[fill.child_id].child + assert child.released_at < fill.timestamp + assert child.expires_at is not None and fill.timestamp < child.expires_at + + +def test_children_are_derived_so_a_rerun_names_and_orders_them_identically() -> None: + first, second = run(), run() + assert digest_run(first).result_id == digest_run(second).result_id + assert first.algorithms is not None and second.algorithms is not None + assert capture_book(first.algorithms) == capture_book(second.algorithms) + + +def test_a_liquidity_cap_applies_to_each_child_not_to_the_parent() -> None: + # A tenth of a bar's 400 is 40. Filled whole, the parent takes 40 at one bar + # and the rest is withdrawn; in four children of 25, each fits under the cap. + capped = LiquidityCappedFill(participation_rate=Decimal("0.1")) + filled_whole = parent_of(whole(fill_policy=capped)) + assert filled_whole.status is OrderStatus.CANCELLED + assert filled_whole.filled_quantity == Decimal("40") + + result = run(fill_policy=capped) + book = result.algorithms + assert book is not None + algorithm = book.algorithm_for(parent_id(result)) + assert algorithm is not None + assert [p.filled for p in algorithm.children.values()] == [Decimal("25")] * 4 + assert all(f.quantity <= Decimal("40") for f in book.fills) + assert parent_of(result).status is OrderStatus.FILLED + + +def test_no_child_ever_executes_more_than_its_parent_left() -> None: + thin = bars(volumes=(Decimal("30"),) * 12) + result = run(dataset=thin, fill_policy=LiquidityCappedFill(participation_rate=Decimal("0.5"))) + book = result.algorithms + assert book is not None + algorithm = book.algorithm_for(parent_id(result)) + assert algorithm is not None + parent = parent_of(result) + # 15 a bar against children of 25: each slice's child fills 15 at the bar + # after it, and what it did not fill is withdrawn. The schedule tops up at + # that bar, but the top-up's next bar is its slice's end, so it expires + # unfilled and the next slice catches it up -- 25, 35, 45, 55. + progress = list(algorithm.children.values()) + assert [p.child.quantity for p in progress[::2]] == [ + Decimal(q) for q in ("25", "35", "45", "55") + ] + assert [p.filled for p in progress[::2]] == [Decimal("15")] * 4 + assert [p.outcome for p in progress[::2]] == [OrderStatus.CANCELLED] * 4 + assert [p.filled for p in progress[1::2]] == [Decimal("0")] * 4 + assert [p.outcome for p in progress[1::2]] == [OrderStatus.EXPIRED] * 4 + # Never more at work than the parent had left. + running = Decimal("0") + for p in progress: + assert p.child.quantity <= parent.quantity - running + running += p.filled + assert parent.filled_quantity == Decimal("60") and parent.status is OrderStatus.EXPIRED + assert algorithm.status is AlgorithmStatus.EXPIRED + + +# --------------------------------------------------------------------------- # +# 4-5. No liquidity, rejection, cancellation; the reservation released once +# --------------------------------------------------------------------------- # + + +def _released_once(result: BacktestResult) -> None: + allocation = result.state.allocation + assert parent_id(result) not in allocation.reservations + assert parent_id(result) not in allocation.contributions + releases = [ + e + for e in allocation.events + if isinstance(e, AllocationReservationReleased) and e.order_id == parent_id(result) + ] + assert len(releases) == 1 + + +def test_no_liquidity_fills_nothing_and_the_parent_expires_with_its_capital_released() -> None: + result = run(dataset=bars(volumes=(Decimal("0"),) * 12), fill_policy=LiquidityCappedFill()) + parent = parent_of(result) + assert parent.status is OrderStatus.EXPIRED and parent.filled_quantity == Decimal("0") + assert ACME not in result.state.portfolio.positions + assert result.state.portfolio.cash.balance("USD") == START_CASH + _released_once(result) + + +def test_a_partially_executed_parent_releases_its_capital_exactly_once() -> None: + result = run( + dataset=bars(volumes=(Decimal("30"),) * 12), + fill_policy=LiquidityCappedFill(participation_rate=Decimal("0.5")), + ) + assert parent_of(result).status is OrderStatus.EXPIRED + _released_once(result) + assert not result.state.allocation.reservations + + +def test_a_child_the_venue_rejects_stops_the_algorithm_and_rejects_the_parent() -> None: + result = run(fill_policy=StaticFill(FillStatus.REJECTED)) + book = result.algorithms + assert book is not None + finished = book.finished[parent_id(result)] + assert finished.status is OrderStatus.REJECTED + assert finished.algorithm is not None and finished.algorithm.status is AlgorithmStatus.FAILED + assert parent_of(result).status is OrderStatus.REJECTED + assert ACME not in result.state.portfolio.positions + _released_once(result) + + +def _declaration(order_types: frozenset[OrderType]) -> CapabilityDeclaration: + return CapabilityDeclaration( + adapter_id="venue", + supported_features=frozenset({Capability.CANCEL_REPLACE}), + unsupported_features=frozenset(), + markets=( + MarketCapability( + AssetType.EQUITY, + ANY_LISTING_VENUE, + order_types, + frozenset({TimeInForce.DAY}), + Support.SUPPORTED, + Support.SUPPORTED, + Support.UNSUPPORTED, + Support.UNSUPPORTED, + ), + ), + unsupported_asset_classes=frozenset({AssetType.OPTION}), + accounts=( + AccountCapability( + "ACC", frozenset({AssetType.EQUITY}), Support.UNSUPPORTED, Support.SUPPORTED + ), + ), + ) + + +def test_every_child_is_checked_against_the_venues_declaration_before_it_trades() -> None: + offers = VenueCheck(_declaration(frozenset({OrderType.MARKET})), "ACC") + accepted = run(ExecutionPlan(TWAP_4, capabilities=offers)) + assert parent_of(accepted).status is OrderStatus.FILLED + + limits_only = VenueCheck(_declaration(frozenset({OrderType.LIMIT})), "ACC") + refused = run(ExecutionPlan(TWAP_4, capabilities=limits_only)) + assert refused.algorithms is not None + finished = refused.algorithms.finished[parent_id(refused)] + assert finished.status is OrderStatus.REJECTED + assert "order_type" in finished.reason and "INCOMPATIBLE" in finished.reason.upper() + assert not refused.algorithms.fills + + +def test_a_split_cancels_the_parent_and_its_algorithm_and_keeps_what_filled() -> None: + split = ScheduledSplit(Split(ACME, Decimal("2")), at(4)) + result = run(schedule=RunSchedule.of(splits=[split])) + parent = parent_of(result) + assert result.algorithms is not None + finished = result.algorithms.finished[parent_id(result)] + assert parent.status is OrderStatus.CANCELLED and finished.status is OrderStatus.CANCELLED + assert finished.algorithm is not None + assert finished.algorithm.status is AlgorithmStatus.CANCELLED + # The two children that filled before the split stand, in the new units. + assert parent.filled_quantity == Decimal("50") + assert result.state.portfolio.positions[ACME].quantity == Decimal("100") + _released_once(result) + + +def test_a_parent_still_working_when_the_data_ends_is_reported_incomplete() -> None: + long_window = ExecutionPlan( + AlgorithmAssignment(TWAP(4, Urgency.neutral()), 40 * MINUTE, Decimal("1")) + ) + result = run(long_window) + book = result.algorithms + assert book is not None + (open_run,) = book.incomplete + assert open_run.status is AlgorithmStatus.WORKING and open_run.remaining > Decimal("0") + parent = parent_of(result) + assert parent.is_open and parent.filled_quantity == open_run.filled + # Still committed, as a resting order's capital is. + assert parent_id(result) in result.state.allocation.reservations + + +# --------------------------------------------------------------------------- # +# 6. No look-ahead +# --------------------------------------------------------------------------- # + + +def test_prices_after_an_instant_change_nothing_the_run_did_before_it() -> None: + base = run() + shocked_prices = PRICES[:5] + tuple(p * 3 for p in PRICES[5:]) + shocked = run(dataset=bars(prices=shocked_prices)) + assert base.algorithms is not None and shocked.algorithms is not None + before = [f for f in base.algorithms.fills if f.timestamp < at(5)] + assert before == [f for f in shocked.algorithms.fills if f.timestamp < at(5)] + assert [f for f in shocked.algorithms.fills if f.timestamp >= at(5)] != [ + f for f in base.algorithms.fills if f.timestamp >= at(5) + ] + + +def test_participation_sizes_only_from_volume_already_printed() -> None: + plan = ExecutionPlan( + AlgorithmAssignment( + Participation(Decimal("0.1"), None, None, IncompletePolicy.LEAVE_UNFILLED), + 8 * MINUTE, + Decimal("1"), + ) + ) + volumes = (Decimal("100"), Decimal("200"), Decimal("300"), Decimal("400")) * 3 + result = run(plan, dataset=bars(volumes=volumes)) + book = result.algorithms + assert book is not None + algorithm = book.algorithm_for(parent_id(result)) + assert algorithm is not None + # At each bar the target is a tenth of the volume printed after the parent + # was placed, through that bar -- the first bar's own volume predates it. + printed = Decimal("0") + for p, minute in zip(algorithm.children.values(), range(1, 9), strict=False): + printed += volumes[minute] + assert p.child.released_at == at(minute) + assert p.child.quantity <= printed / 10 + later = run(plan, dataset=bars(volumes=volumes[:4] + (Decimal("9000"),) * 8)) + assert later.algorithms is not None + early = [p.child for p in algorithm.children.values() if p.child.released_at < at(4)] + same = later.algorithms.algorithm_for(parent_id(later)) + assert same is not None + assert early == [p.child for p in same.children.values() if p.child.released_at < at(4)] + + +# --------------------------------------------------------------------------- # +# 7. Costs, and every report against the books +# --------------------------------------------------------------------------- # + + +def test_every_child_fill_pays_the_runs_costs_and_the_books_balance() -> None: + result = run() + book = result.algorithms + assert book is not None + for fill in book.fills: + close = PRICES[int((fill.timestamp - T0) / MINUTE)] + assert fill.price == close + HALF_SPREAD + assert fill.commission == COMMISSION + spent = sum(f.quantity * f.price + f.commission for f in book.fills) + assert result.state.portfolio.cash.balance("USD") == START_CASH - spent + assert len(result.state.trade_records) == len(book.fills) + assert result.valuation.commission_paid == COMMISSION * len(book.fills) + + +def test_algorithmic_children_are_costed_against_the_same_run_filling_whole() -> None: + plain = whole() + sliced = run() + assert sliced.algorithms.fills + (fill,) = tuple(plain.state.execution.history) + # Filled whole at the next bar, one commission; in four children, four. + assert plain.valuation.commission_paid == COMMISSION + assert sliced.valuation.commission_paid == COMMISSION * 4 + assert fill.fill_price == PRICES[1] + HALF_SPREAD + + +# --------------------------------------------------------------------------- # +# 8. Attribution +# --------------------------------------------------------------------------- # + + +def test_one_netted_parent_is_attributed_to_both_strategies_on_every_child_fill() -> None: + scripts = { + "A": {at(0): (Decimal("60"), MARKET)}, + "B": {at(0): (Decimal("40"), MARKET)}, + } + result = run(scripts=scripts) + parent = parent_of(result) + assert parent.strategy_id == "" and parent.filled_quantity == Decimal("100") + algorithm = result.algorithms.algorithm_for(parent_id(result)) if result.algorithms else None + assert algorithm is not None + assert {c.strategy_id: c.quantity for c in algorithm.parent.contributions} == { + "A": Decimal("60"), + "B": Decimal("40"), + } + for record in result.state.trade_records: + assert {c.strategy_id for c in record.contributions} == {"A", "B"} + positions = result.state.allocation.strategy_positions + assert positions is not None + assert positions["A"][ACME] == Decimal("60") and positions["B"][ACME] == Decimal("40") + + +# --------------------------------------------------------------------------- # +# 9-10. Replay parity, pauses through a payload, restoration +# --------------------------------------------------------------------------- # + + +def replay(plan: ExecutionPlan = TWAP_PLAN, **settings: Any) -> Any: + return ReplayBacktest.run( + config(**settings), bars(), strategies(BUY_100), context_factory, execution=plan + ) + + +def test_the_replay_works_every_child_as_the_backtest_does() -> None: + backtest = run() + replayed = replay() + assert replayed.algorithms is not None + assert capture_book(replayed.algorithms) == capture_book(backtest.algorithms) + assert replayed.backtest.fills == backtest.fills + assert replayed.backtest.valuation == backtest.valuation + + +def _fresh_objects(settings: Mapping[str, Any]) -> RunObjects: + cfg = config(**settings) + return RunObjects( + pipeline=RuntimeObjects( + sizing_model=cfg.pipeline.sizing_model, + simulator=cfg.pipeline.simulator, + strategies={"S": Orders("S", BUY_100["S"])}, + instruments=REGISTRY, + ), + fill_policy=cfg.fill_policy, + ) + + +@pytest.mark.parametrize("pause", range(1, 12)) +def test_a_replay_paused_with_children_working_resumes_from_its_payload(pause: int) -> None: + plan = ExecutionPlan(TWAP_4) + uninterrupted = replay(plan) + paused = ReplayBacktest.run( + config(), + bars(), + strategies(BUY_100), + context_factory, + pause_when=lambda progress: progress.records_replayed == pause, + execution=plan, + ) + assert isinstance(paused, PausedReplay) + payload = serialize(capture_replay(paused)) + restored = restore_replay( + replay_from_primitives(deserialize(payload)), + _fresh_objects({}), + bars(), + RunSchedule(), + plan, + ) + finished = ReplayBacktest.resume(restored, context_factory) + assert digest_run(finished.backtest).result_id == digest_run(uninterrupted.backtest).result_id + assert finished.algorithms is not None and uninterrupted.algorithms is not None + assert capture_book(finished.algorithms) == capture_book(uninterrupted.algorithms) + + +def test_a_payload_resumed_with_another_plan_or_none_is_refused() -> None: + plan = ExecutionPlan(TWAP_4) + paused = ReplayBacktest.run( + config(), + bars(), + strategies(BUY_100), + context_factory, + pause_when=lambda progress: progress.records_replayed == 3, + execution=plan, + ) + assert isinstance(paused, PausedReplay) + snapshot = replay_from_primitives(deserialize(serialize(capture_replay(paused)))) + other = ExecutionPlan(AlgorithmAssignment(TWAP(2, Urgency.neutral()), 8 * MINUTE, Decimal("1"))) + with pytest.raises(StateDecodeError, match="another way"): + restore_replay(snapshot, _fresh_objects({}), bars(), RunSchedule(), other) + with pytest.raises(StateDecodeError, match="no execution plan was supplied"): + restore_replay(snapshot, _fresh_objects({}), bars(), RunSchedule()) + + +def test_a_book_round_trips_and_an_altered_one_is_refused() -> None: + plan = ExecutionPlan(TWAP_4) + book = run(plan).algorithms + assert book is not None + payload = deserialize(serialize(capture_book(book))) + assert capture_book(book_from_primitives(payload, plan)) == capture_book(book) + finished = payload["finished"][0]["algorithm"] + finished["parent"]["quantity"] = "101" + with pytest.raises(ExecutionPlanError, match="altered"): + book_from_primitives(payload, plan) + + +# --------------------------------------------------------------------------- # +# Every algorithm the plan admits, worked through a backtest +# --------------------------------------------------------------------------- # + + +PROFILE = VolumeProfile( + tuple(IntervalVolume(at(2 * n), at(2 * n + 2), Decimal(v)) for n, v in enumerate((1, 3, 3, 1))), + "test profile: front and back light", +) + + +@pytest.mark.parametrize( + ("algorithm", "children"), + [ + (TWAP(4, Urgency.neutral()), ["25", "25", "25", "25"]), + (VWAP(PROFILE, MissingVolumePolicy.REFUSE, Urgency.neutral()), ["13", "38", "37", "12"]), + (Slicing(Decimal("30"), None), ["30", "30", "30", "10"]), + (Iceberg(Decimal("20")), ["20", "20", "20", "20", "20"]), + ], + ids=["twap", "vwap", "slicing", "iceberg"], +) +def test_each_schedule_and_slicing_algorithm_works_its_parent_through_the_backtest( + algorithm: Any, children: list[str] +) -> None: + result = run(ExecutionPlan(AlgorithmAssignment(algorithm, 8 * MINUTE, Decimal("1")))) + book = result.algorithms + assert book is not None + worked = book.algorithm_for(parent_id(result)) + assert worked is not None and worked.status is AlgorithmStatus.COMPLETED + assert [p.child.quantity for p in worked.children.values()] == [Decimal(c) for c in children] + assert parent_of(result).status is OrderStatus.FILLED + + +def test_a_limit_parents_children_rest_at_its_limit_and_fill_only_when_crossed() -> None: + limit = Decimal("100.30") + scripts = { + "S": { + at(0): ( + Decimal("100"), + OrderTerms(OrderType.LIMIT, limit, time_in_force=TimeInForce.GTC), + ) + } + } + result = run(scripts=scripts) + book = result.algorithms + assert book is not None + algorithm = book.algorithm_for(parent_id(result)) + assert algorithm is not None + assert all(p.child.order_type is OrderType.LIMIT for p in algorithm.children.values()) + assert all(p.child.limit_price == limit for p in algorithm.children.values()) + # A bar whose low reaches the limit fills the child as a maker: at the + # limit, or at the bar's open when it opened through it. Never above it. + for fill in book.fills: + opened = PRICES[int((fill.timestamp - T0) / MINUTE)] + assert opened - Decimal("0.5") <= limit + assert fill.price == min(opened, limit) and fill.commission == COMMISSION + assert book.fills + + +# --------------------------------------------------------------------------- # +# 11. What is not supported fails explicitly +# --------------------------------------------------------------------------- # + + +def test_participation_that_trades_after_its_window_is_refused_by_the_plan() -> None: + with pytest.raises(ExecutionPlanError, match="COMPLETE_AT_END"): + AlgorithmAssignment( + Participation(Decimal("0.1"), None, None, IncompletePolicy.COMPLETE_AT_END), + MINUTE, + Decimal("1"), + ) + + +@pytest.mark.parametrize( + "terms", + [ + OrderTerms(OrderType.STOP, stop_price=Decimal("90")), + OrderTerms(OrderType.MARKET, time_in_force=TimeInForce.IOC), + OrderTerms.at_open(), + ], + ids=["stop", "immediate", "auction"], +) +def test_terms_an_algorithm_cannot_work_are_refused_before_an_order_exists( + terms: OrderTerms, +) -> None: + result = run(scripts={"S": {at(0): (Decimal("100"), terms)}}) + assert not tuple(result.state.oms.orders.orders()) + assert result.algorithms is not None and not result.algorithms.fills + assert result.state.portfolio.cash.balance("USD") == START_CASH + + +def test_a_parent_no_algorithm_can_work_is_rejected_not_filled_whole() -> None: + lots = ExecutionPlan(AlgorithmAssignment(TWAP(4, Urgency.neutral()), 8 * MINUTE, Decimal("30"))) + result = run(lots) + assert result.algorithms is not None + finished = result.algorithms.finished[parent_id(result)] + assert finished.status is OrderStatus.REJECTED and finished.algorithm is None + assert "whole number" in finished.reason + assert ACME not in result.state.portfolio.positions + + elsewhere = ExecutionPlan(by_asset=(("ELSEWHERE", TWAP_4),)) + unassigned = run(elsewhere) + assert unassigned.algorithms is not None + assert "No execution algorithm is assigned" in ( + unassigned.algorithms.finished[parent_id(unassigned)].reason + ) + + +def test_an_algorithmic_routing_without_a_plan_or_outside_history_is_refused() -> None: + held = replace( + config(), pipeline=replace(config().pipeline, routing=ExecutionRouting.ALGORITHMIC) + ) + with pytest.raises(ExecutionPlanError, match="no execution plan"): + BacktestEngine.run(held, bars(), strategies(BUY_100), context_factory) + with pytest.raises(RuntimeValidationError, match="cannot route ALGORITHMIC"): + replace(held, mode=ExecutionMode.LIVE) + + +# --------------------------------------------------------------------------- # +# 12. Runs given no plan are the runs they were +# --------------------------------------------------------------------------- # + + +def test_a_run_given_no_plan_is_untouched() -> None: + plain = whole() + assert type(plain) is BacktestResult + assert plain.config.pipeline.routing is ExecutionRouting.SIMULATED + assert parent_of(plain).filled_quantity == Decimal("100") + assert len(plain.state.execution.history) == 1 + assert plain.config.execution_assumptions.simulated + + +# --------------------------------------------------------------------------- # +# The live route works the same children +# --------------------------------------------------------------------------- # + + +def test_the_backtests_children_are_the_ones_the_live_route_sends() -> None: + """Child for child, the backtest releases what the live route sends a venue. + + The algorithm is driven by hand at the bars the backtest saw, each child sent + to a ``PaperBroker`` with ``route_child_order``, as a live session sends it. + The venue fills each market child in full, as ``ImmediateFill`` does, so both + runs release the same children, with the same ids and quantities, at the same + instants. The prices differ by design: the paper venue sees no market data and + fills at the parent's reference price, where the backtest fills at the bar. + """ + + result = run(fill_policy=ImmediateFill(), simulator=ExecutionSimulator(cost_model=FREE)) + book = result.algorithms + assert book is not None + worked = book.algorithm_for(parent_id(result)) + assert worked is not None + final = parent_of(result) + parent = replace( + final, + status=OrderStatus.ACCEPTED, + filled_quantity=Decimal("0"), + remaining_quantity=final.quantity, + ) + + broker = PaperBroker(FREE) + venue = BrokerEngine.initialize("PAPER", START_CASH, "USD") + venue, _ = broker.connect(venue, T0) + bindings = ChildOrderBindings() + algorithm = start_algorithm(worked.parent, worked.algorithm, worked.terms) + sent = [] + for minute in range(12): + now = at(minute) + algorithm, released = release_children(algorithm, now) + for child in released: + routed = route_child_order( + venue, + broker, + parent, + child, + now, + mapping=ExternalOrderMap(), + children=bindings, + config=RoutingConfig(venue="PAPER", currency="USD", order_type=OrderType.MARKET), + capability=None, + ) + assert routed.decision.routed, routed.decision.reason + venue, bindings = routed.broker_state, routed.children + sent.append(child) + algorithm = record_child_execution( + algorithm, child.child_id, f"PX-{child.sequence}", child.quantity, now + ) + parent = replace(parent, remaining_quantity=parent.remaining_quantity - child.quantity) + assert [(c.child_id, c.quantity, c.released_at) for c in sent] == [ + (p.child.child_id, p.child.quantity, p.child.released_at) for p in worked.children.values() + ] + assert algorithm.status is AlgorithmStatus.COMPLETED + + +def test_terms_are_the_plans_window_from_the_instant_the_parent_was_placed() -> None: + result = run() + assert result.algorithms is not None + worked = result.algorithms.algorithm_for(parent_id(result)) + assert worked is not None + assert worked.terms == AlgorithmTerms(at(0), at(8), Decimal("1"), OrderType.MARKET, None) + + +# --------------------------------------------------------------------------- # +# The pipeline's own guards on a child, and the plan's +# --------------------------------------------------------------------------- # + + +def _held() -> tuple[BacktestResult, Any, Any]: + """A run whose data ends with a child working: the parent, and that child.""" + + window = ExecutionPlan( + AlgorithmAssignment(TWAP(4, Urgency.neutral()), 40 * MINUTE, Decimal("1")) + ) + result = run(window, dataset=bars(PRICES[:1], VOLUMES[:1])) + assert result.algorithms is not None + (algorithm,) = result.algorithms.incomplete + (child,) = algorithm.working_children + return result, algorithm, child + + +def _event(minute: int, asset: str = ACME) -> Any: + price = Decimal("101") + bar = Bar( + asset, at(minute), price, price, price, price, Decimal("400"), None, None, TimeFrame.M1 + ) + return BarClosed(event_id=f"E-{minute}", timestamp=at(minute), bar=bar) + + +def test_a_child_cannot_work_more_than_its_parent_has_left() -> None: + result, _, child = _held() + remaining = parent_of(result).remaining_quantity + with pytest.raises(RuntimeValidationError, match="short of filled"): + ExecutionPipeline.work_child( + result.state, child, remaining + 1, _event(9), ImmediateFill(), arriving=False + ) + with pytest.raises(RuntimeValidationError, match="short of filled"): + ExecutionPipeline.work_child( + result.state, child, Decimal("0"), _event(9), ImmediateFill(), arriving=False + ) + + +def test_a_child_is_worked_only_at_an_event_of_its_own_asset() -> None: + result, _, child = _held() + with pytest.raises(RuntimeValidationError, match="the event prices"): + ExecutionPipeline.work_child( + result.state, child, child.quantity, _event(9, "OTHER"), ImmediateFill(), arriving=False + ) + + +def test_a_child_names_a_parent_the_run_holds_and_routes_algorithmically() -> None: + result, _, child = _held() + stranger = replace(child, parent_order_id="00000000-0000-4000-8000-000000000000") + with pytest.raises(RuntimeValidationError, match="unknown parent"): + ExecutionPipeline.work_child( + result.state, stranger, child.quantity, _event(9), ImmediateFill(), arriving=False + ) + simulated = replace( + result.state, + config=replace(result.state.config, routing=ExecutionRouting.SIMULATED), + ) + with pytest.raises(RuntimeValidationError, match="only under ALGORITHMIC"): + ExecutionPipeline.work_child( + simulated, child, child.quantity, _event(9), ImmediateFill(), arriving=False + ) + + +def test_a_child_worked_by_hand_settles_on_its_parent_as_the_driver_does() -> None: + result, _, child = _held() + before = parent_of(result).filled_quantity + turn = ExecutionPipeline.work_child( + result.state, child, child.quantity, _event(9), ImmediateFill(), arriving=False + ) + (report,) = turn.reports + assert report.order_id == child.parent_order_id and turn.filled == child.quantity + assert turn.outcome is OrderStatus.FILLED + (after,) = turn.state.oms.orders.orders() + assert after.filled_quantity == before + child.quantity + + +@pytest.mark.parametrize( + ("build", "message"), + [ + (lambda: AlgorithmAssignment(TWAP(2, Urgency.neutral()), 0.0, Decimal("1")), "no time"), + ( + lambda: AlgorithmAssignment(TWAP(2, Urgency.neutral()), float("nan"), Decimal("1")), + "no time", + ), + (lambda: AlgorithmAssignment(TWAP(2, Urgency.neutral()), MINUTE, Decimal("0")), "unit"), + (lambda: ExecutionPlan(), "assigns no algorithm"), + (lambda: ExecutionPlan(by_asset=((ACME, TWAP_4), (ACME, TWAP_4))), "repeats"), + (lambda: VenueCheck(_declaration(frozenset({OrderType.MARKET})), " "), "no account"), + ], + ids=["no-window", "nan-window", "no-increment", "empty", "repeated-asset", "no-account"], +) +def test_a_plan_that_cannot_work_anything_is_refused(build: Any, message: str) -> None: + with pytest.raises(ExecutionPlanError, match=message): + build() + + +def test_a_plans_identity_is_its_content() -> None: + by_asset = ExecutionPlan(by_asset=((ACME, TWAP_4), ("B", TWAP_4))) + reordered = ExecutionPlan(by_asset=(("B", TWAP_4), (ACME, TWAP_4))) + assert by_asset.plan_id == reordered.plan_id + assert by_asset.assignment_for(ACME) is TWAP_4 and by_asset.assignment_for("C") is None + assert ExecutionPlan(TWAP_4).plan_id != by_asset.plan_id + slower = AlgorithmAssignment(TWAP(4, Urgency.neutral()), 9 * MINUTE, Decimal("1")) + assert ExecutionPlan(slower).plan_id != ExecutionPlan(TWAP_4).plan_id + checked = ExecutionPlan( + TWAP_4, capabilities=VenueCheck(_declaration(frozenset({OrderType.MARKET})), "ACC") + ) + assert checked.plan_id != ExecutionPlan(TWAP_4).plan_id + + +# --------------------------------------------------------------------------- # +# Fill timing: the run's own rule, applied to every child +# --------------------------------------------------------------------------- # + + +def test_under_same_event_a_child_trades_at_the_bar_it_was_released_at() -> None: + result = run(timing=FillTiming.SAME_EVENT) + book = result.algorithms + assert book is not None + # Released and filled at each slice's first bar; never filled whole. + assert [f.timestamp for f in book.fills] == [at(0), at(2), at(4), at(6)] + assert [f.quantity for f in book.fills] == [Decimal("25")] * 4 + assert [f.price for f in book.fills] == [PRICES[m] + HALF_SPREAD for m in (0, 2, 4, 6)] + assert result.config.execution_assumptions.optimistic + + +def test_a_child_never_trades_at_the_instant_it_was_released_under_next_event() -> None: + inputs: list[Any] = [] + for n, p in enumerate(PRICES): + inputs.append(Bar(ACME, at(n), p, p, p, p, Decimal("400"), None, None, TimeFrame.M1)) + # A quote at the same instant, after the bar: the same moment, not a later one. + inputs.append(sized_quote(ACME, at(n), p, Decimal("400"))) + result = run(dataset=MarketDataset.of("ALGO-TWO-A-MINUTE", inputs)) + book = result.algorithms + assert book is not None and book.fills + for fill in book.fills: + algorithm = book.algorithm_for(fill.parent_order_id) + assert algorithm is not None + assert algorithm.children[fill.child_id].child.released_at < fill.timestamp diff --git a/tests/integration/test_bonds_on_the_path.py b/tests/integration/test_bonds_on_the_path.py new file mode 100644 index 00000000..10309886 --- /dev/null +++ b/tests/integration/test_bonds_on_the_path.py @@ -0,0 +1,163 @@ +"""A fixed-rate bond on the execution path: coupons and redemption from its terms (v4.1.1, FUT-010). + +``AssetType.BOND`` declares economics as a future does -- a unit's multiplier +on the quoted price -- and its maturity as its last trading instant. +``alphalab.api.bond_schedule`` turns a :class:`~alphalab.macro.bond.Bond`'s +terms into per-unit coupons and a redemption at par, which the run applies at +their instants to whoever holds. Trading at full (dirty) prices carries the +accrual in the price. Each figure is checked against the bond's own arithmetic. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import replace +from datetime import UTC, date, datetime +from decimal import Decimal +from typing import Any + +import pytest + +from alphalab.api import bond_schedule +from alphalab.backtesting import BacktestEngine, BacktestResult, ScheduledDistribution +from alphalab.backtesting.dataset import MarketDataset +from alphalab.conventions.daycount import DayCount +from alphalab.conventions.economics import InstrumentEconomics, SettlementModel +from alphalab.conventions.rates import Compounding +from alphalab.core.enums import AssetType +from alphalab.data import DataValidationError +from alphalab.instrument.economics import economics_for +from alphalab.instrument.exceptions import InstrumentInputError +from alphalab.instrument.record import InstrumentRecord +from alphalab.macro.bond import Bond, cash_flows +from alphalab.strategy.context import StrategyContext +from alphalab.strategy.events import Intent, IntentKind +from alphalab.strategy.protocol import BaseStrategy +from tests.integration.harness import ( + START_CASH, + backtest_config, + context_factory, + quote, + registry_of, + running_strategy_state, +) + +STRATEGY = "BONDS" +BOND = Bond( + face=Decimal("1000"), + coupon_rate=0.05, + frequency=Compounding.SEMI_ANNUAL, + issue_date=date(2024, 1, 15), + maturity=date(2025, 7, 15), + day_count=DayCount.THIRTY_360_US, + currency="USD", +) + + +def at(day: date) -> float: + """A flow is fixed at midnight UTC of its date.""" + + return datetime(day.year, day.month, day.day, tzinfo=UTC).timestamp() + + +MATURITY = at(BOND.maturity) +NOTE = InstrumentRecord( + "UST25", + AssetType.BOND, + "XGOV", + "USD", + # Quoted per 100 of face; one unit is one bond of 1,000 face. + economics=InstrumentEconomics(Decimal("10"), SettlementModel.CASH_EQUITY), + last_trade_at=MATURITY, +) +DAY = 86_400.0 +#: Full (dirty) prices per 100 of face, one a month from February 2024. +PRICES = {at(date(2024, 2, 1)) + n * 30 * DAY: "101.00" for n in range(17)} +FIRST = min(PRICES) + + +class Buyer(BaseStrategy): + def __init__(self, quantity: Decimal) -> None: + self._quantity = quantity + + def on_quote(self, context: StrategyContext, event: Any) -> Iterable[Intent]: + if event.quote.timestamp != FIRST: + return () + return ( + Intent( + STRATEGY, + NOTE.asset_id, + self._quantity, + timestamp=FIRST, + kind=IntentKind.TARGET_QUANTITY, + ), + ) + + +def _run(quantity: Decimal = Decimal("10")) -> BacktestResult: + base = backtest_config(STRATEGY) + config = replace(base, pipeline=replace(base.pipeline, instruments=registry_of(NOTE))) + records = [quote(NOTE.asset_id, ts, Decimal(px)) for ts, px in sorted(PRICES.items())] + # A last price after maturity: the bond no longer trades, and the record is skipped. + records.append(quote(NOTE.asset_id, MATURITY + DAY, Decimal("100"))) + return BacktestEngine.run( + config, + MarketDataset.of("BONDS", records), + running_strategy_state(STRATEGY, Buyer(quantity)), + context_factory, + schedule=bond_schedule(BOND, NOTE, at, redemption_price=Decimal("100")), + ) + + +def test_the_schedule_is_the_bonds_coupons_and_its_redemption() -> None: + schedule = bond_schedule(BOND, NOTE, at, redemption_price=Decimal("100")) + coupons = [e.item for e in schedule.entries if isinstance(e.item, ScheduledDistribution)] + assert [c.at for c in coupons] == [at(flow.payment_date) for flow in cash_flows(BOND)] + assert all(c.per_unit == Decimal("25.00") for c in coupons) + assert schedule.entries[-1].item.settlement.price == Decimal("100") + assert schedule.entries[-1].at == MATURITY + + +def test_coupons_and_redemption_reach_the_holder_and_the_books_balance() -> None: + result = _run() + held_coupons = [flow for flow in cash_flows(BOND) if at(flow.payment_date) > FIRST] + coupons = Decimal("10") * Decimal("25.00") * len(held_coupons) + # Bought 10 bonds at 101 per 100 (1,010 each); redeemed at 100 (1,000 each). + capital = Decimal("10") * (Decimal("100") - Decimal("101")) * Decimal("10") + + assert NOTE.asset_id not in result.state.portfolio.positions + assert result.valuation.realized_pnl == coupons + capital + assert result.state.portfolio.cash.balance("USD") == START_CASH + coupons + capital + valuation = result.valuation + assert valuation.equity == ( + START_CASH + valuation.realized_pnl + valuation.unrealized_pnl - valuation.commission_paid + ) + assert [s.record.timestamp for s in result.run.skipped] == [MATURITY + DAY] + + +def test_a_short_pays_the_coupons() -> None: + long = _run(Decimal("10")).valuation.realized_pnl + short = _run(Decimal("-10")).valuation.realized_pnl + assert long == -short + + +def test_a_bond_that_declares_no_economics_cannot_be_booked() -> None: + with pytest.raises(InstrumentInputError, match="declares no economics"): + economics_for(AssetType.BOND, None, "UST25") + + +@pytest.mark.parametrize( + ("instrument", "message"), + [ + (replace(NOTE, asset_type=AssetType.EQUITY), "is a equity"), + (replace(NOTE, last_trade_at=None), "must stop trading"), + (replace(NOTE, last_trade_at=MATURITY + DAY), "must stop trading"), + (replace(NOTE, currency="EUR"), "trades in EUR"), + ], + ids=["not-a-bond", "no-maturity", "trades-past-maturity", "other-currency"], +) +def test_a_schedule_for_the_wrong_instrument_is_refused( + instrument: InstrumentRecord, message: str +) -> None: + with pytest.raises(DataValidationError, match=message): + bond_schedule(BOND, instrument, at, redemption_price=Decimal("100")) diff --git a/tests/integration/test_classification_limits.py b/tests/integration/test_classification_limits.py index 71e16f44..5bbd8ba9 100644 --- a/tests/integration/test_classification_limits.py +++ b/tests/integration/test_classification_limits.py @@ -182,7 +182,7 @@ def test_dimensions_survive_a_registry_snapshot_and_rebuild_the_index() -> None: payload = deserialize(serialize(capture_registry(registry))) restored = restore_registry(registry_from_primitives(payload)) - assert payload["schema_version"] == 3 + assert payload["schema_version"] == 4 # v4.1.1: a last trading instant (FUT-012) assert restored == registry assert set(restored.bucket_members(ISSUER, "Acme Corp")) == { ACME_A.asset_id, diff --git a/tests/integration/test_contract_expiry.py b/tests/integration/test_contract_expiry.py new file mode 100644 index 00000000..33bb6592 --- /dev/null +++ b/tests/integration/test_contract_expiry.py @@ -0,0 +1,430 @@ +"""A contract stops trading at its expiry, and its final settlement closes what is held (FUT-012). + +Until v4.1.1 an instrument declaration carried no expiry, so a future or option +held past its last trading day stayed on the book, marked at its last price -- +a position the contract could not have had -- unless the strategy traded it +out. ``InstrumentRecord.last_trade_at`` declares the instant; after it the +path skips the contract's records and refuses its orders, a +:class:`~alphalab.portfolio.corporate_actions.ContractSettlement` closes every +position at the exchange's final price (a future's last variation, an option's +intrinsic value), and a run that ends still holding an expired contract is +refused. Each property is checked against the arithmetic it must produce. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import replace +from decimal import Decimal +from typing import Any + +import pytest + +from alphalab.backtesting import ( + BacktestEngine, + BacktestResult, + PausedReplay, + ReplayBacktest, + ReplayResult, + RunSchedule, + ScheduledSettlement, + replay_snapshot, +) +from alphalab.backtesting.dataset import MarketDataset +from alphalab.common.order_terms import OrderTerms +from alphalab.conventions.economics import InstrumentEconomics, SettlementModel +from alphalab.conventions.lot import LotSpecification +from alphalab.core.enums import AssetType +from alphalab.instrument.record import InstrumentRecord +from alphalab.instrument.snapshot import capture as capture_registry +from alphalab.instrument.snapshot import from_primitives as registry_from_primitives +from alphalab.instrument.snapshot import restore as restore_registry +from alphalab.lifecycle import digest_run +from alphalab.persistence import deserialize, serialize +from alphalab.portfolio.corporate_actions import ContractSettlement +from alphalab.runtime.exceptions import RuntimeValidationError +from alphalab.runtime.run import RunConfig, RunEngine +from alphalab.runtime.run_snapshot import RunObjects +from alphalab.runtime.snapshot import RuntimeObjects +from alphalab.strategy.context import StrategyContext +from alphalab.strategy.events import Intent, IntentKind +from alphalab.strategy.protocol import BaseStrategy +from tests.integration.harness import ( + START_CASH, + backtest_config, + context_factory, + quote, + registry_of, + running_strategy_state, +) + +STRATEGY = "EXPIRY" +EXPIRY = 5.0 + + +def _economics(multiplier: str, model: SettlementModel) -> InstrumentEconomics: + return InstrumentEconomics( + Decimal(multiplier), model, lot=LotSpecification(Decimal("1"), Decimal("1")) + ) + + +FUTURE = InstrumentRecord( + "ESH6", + AssetType.FUTURE, + "XCME", + "USD", + economics=_economics("50", SettlementModel.FUTURES_VARIATION), + last_trade_at=EXPIRY, +) +CALL = InstrumentRecord( + "SPXC100", + AssetType.OPTION, + "XCBO", + "USD", + economics=_economics("100", SettlementModel.OPTION_PREMIUM), + last_trade_at=EXPIRY, +) +SHARE = InstrumentRecord("ACME", AssetType.EQUITY, "XNAS", "USD") + + +class Holder(BaseStrategy): + """Holds a target in each asset from chosen instants; may keep asking after expiry.""" + + def __init__(self, plan: dict[tuple[float, str], Decimal]) -> None: + self._plan = plan + + def on_quote(self, context: StrategyContext, event: Any) -> Iterable[Intent]: + target = self._plan.get((event.quote.timestamp, event.quote.asset_id)) + if target is None: + return () + return ( + Intent( + STRATEGY, + event.quote.asset_id, + target, + timestamp=event.quote.timestamp, + kind=IntentKind.TARGET_QUANTITY, + ), + ) + + +def _config(*records: InstrumentRecord) -> RunConfig: + base = backtest_config(STRATEGY) + return replace(base, pipeline=replace(base.pipeline, instruments=registry_of(*records))) + + +def _dataset(asset_id: str, mids: dict[float, str], extra: Iterable[Any] = ()) -> MarketDataset: + items = [quote(asset_id, at, Decimal(mid)) for at, mid in mids.items()] + return MarketDataset.of("EXP", sorted([*items, *extra], key=lambda item: item.timestamp)) + + +def _run( + record: InstrumentRecord, + mids: dict[float, str], + plan: dict[tuple[float, str], Decimal], + settlements: Iterable[ScheduledSettlement] = (), + extra: Iterable[Any] = (), + records: tuple[InstrumentRecord, ...] = (), +) -> BacktestResult: + return BacktestEngine.run( + _config(record, *records), + _dataset(record.asset_id, mids, extra), + running_strategy_state(STRATEGY, Holder(plan)), + context_factory, + schedule=RunSchedule.of(settlements=settlements), + ) + + +def _settled(asset_id: str, price: str, at: float = EXPIRY) -> ScheduledSettlement: + return ScheduledSettlement(ContractSettlement(asset_id, Decimal(price)), at) + + +FUTURE_MIDS = {2.0: "4000", 3.0: "4010", 4.0: "4020", 5.0: "4030", 6.0: "4100", 7.0: "4200"} + + +def _identity(result: BacktestResult) -> None: + valuation = result.valuation + assert valuation.equity == ( + START_CASH + valuation.realized_pnl + valuation.unrealized_pnl - valuation.commission_paid + ) + + +# --------------------------------------------------------------------------- # +# A cash-settled future +# --------------------------------------------------------------------------- # + + +def test_a_future_held_to_expiry_is_closed_at_its_final_settlement() -> None: + result = _run( + FUTURE, + FUTURE_MIDS, + {(2.0, FUTURE.asset_id): Decimal("2")}, + settlements=(_settled(FUTURE.asset_id, "4035"),), + ) + + assert FUTURE.asset_id not in result.state.portfolio.positions + # Bought 2 at 4000, settled at 4035: 2 x 35 x 50, all of it realized. + assert result.valuation.realized_pnl == Decimal("3500.00") + assert result.valuation.unrealized_pnl == Decimal("0") + assert result.state.portfolio.cash.balance("USD") == START_CASH + Decimal("3500") + _identity(result) + + +def test_the_settlement_is_attributed_to_the_strategy_that_held_the_contract() -> None: + result = _run( + FUTURE, + FUTURE_MIDS, + {(2.0, FUTURE.asset_id): Decimal("2")}, + settlements=(_settled(FUTURE.asset_id, "4035"),), + ) + (record,) = [r for r in result.state.trade_records if r.trade_id.startswith("SETTLEMENT-")] + assert record.asset_id == FUTURE.asset_id + assert [(c.strategy_id, c.quantity) for c in record.contributions] == [(STRATEGY, Decimal("2"))] + positions = result.state.allocation.strategy_positions + assert positions is not None + assert FUTURE.asset_id not in positions[STRATEGY] + + +def test_after_expiry_the_contracts_records_are_skipped_and_its_orders_refused() -> None: + result = _run( + FUTURE, + FUTURE_MIDS, + {(2.0, FUTURE.asset_id): Decimal("2"), (6.0, FUTURE.asset_id): Decimal("5")}, + settlements=(_settled(FUTURE.asset_id, "4035"),), + ) + + skipped = [(s.record.timestamp, s.reason) for s in result.run.skipped] + assert [at for at, _ in skipped] == [6.0, 7.0] + assert all("stopped trading" in reason for _, reason in skipped) + assert all(order.created_at <= EXPIRY for order in result.orders) + assert all(fill.filled_at <= EXPIRY for fill in result.fills) + + +def test_a_request_after_expiry_is_refused_before_an_order_exists() -> None: + """Another asset's quote at 6.0 asks for the expired future: dropped, reported.""" + + config = _config(FUTURE, SHARE) + plan = {(2.0, FUTURE.asset_id): Decimal("2"), (6.0, SHARE.asset_id): Decimal("1")} + + class AsksForTheExpired(Holder): + def on_quote(self, context: StrategyContext, event: Any) -> Iterable[Intent]: + if event.quote.asset_id == SHARE.asset_id and event.quote.timestamp == 6.0: + return (Intent(STRATEGY, FUTURE.asset_id, Decimal("3"), timestamp=6.0),) + return super().on_quote(context, event) + + state = RunEngine.initialize(config, running_strategy_state(STRATEGY, AsksForTheExpired(plan))) + dataset = _dataset( + FUTURE.asset_id, {2.0: "4000", 3.0: "4010"}, [quote(SHARE.asset_id, 6.0, Decimal("10"))] + ) + for record in dataset.records: + state, result = RunEngine.advance(state, record, context_factory) + assert result is not None + (refusal,) = result.settlement_refusals + assert refusal.asset_id == FUTURE.asset_id and "stopped trading" in refusal.detail + assert not [order for order in state.pipeline.oms.orders.orders() if order.created_at == 6.0] + + +# --------------------------------------------------------------------------- # +# A cash-settled option: exercised at intrinsic value, or worthless +# --------------------------------------------------------------------------- # + +CALL_MIDS = {2.0: "5.00", 3.0: "6.00", 4.0: "7.00", 5.0: "8.00"} + + +@pytest.mark.parametrize( + ("quantity", "intrinsic", "realized"), + [ + (Decimal("1"), "12.50", Decimal("750.00")), + (Decimal("1"), "0", Decimal("-500.00")), + (Decimal("-1"), "12.50", Decimal("-750.00")), + (Decimal("-1"), "0", Decimal("500.00")), + ], + ids=["exercised", "expired-worthless", "assigned", "written-and-expired"], +) +def test_an_option_is_settled_at_its_intrinsic_value( + quantity: Decimal, intrinsic: str, realized: Decimal +) -> None: + result = _run( + CALL, + CALL_MIDS, + {(2.0, CALL.asset_id): quantity}, + settlements=(_settled(CALL.asset_id, intrinsic),), + ) + # Bought (or written) 1 at 5.00 x 100; settled at the intrinsic value x 100: + # the writer of an exercised option pays it. + assert result.valuation.realized_pnl == realized + assert result.state.portfolio.cash.balance("USD") == START_CASH + realized + assert CALL.asset_id not in result.state.portfolio.positions + _identity(result) + + +# --------------------------------------------------------------------------- # +# Refusals +# --------------------------------------------------------------------------- # + + +def test_a_run_that_ends_holding_an_expired_contract_is_refused() -> None: + with pytest.raises(RuntimeValidationError, match="past their last trading instant"): + _run(FUTURE, FUTURE_MIDS, {(2.0, FUTURE.asset_id): Decimal("2")}) + + +def test_a_contract_closed_before_expiry_needs_no_settlement() -> None: + result = _run( + FUTURE, + FUTURE_MIDS, + {(2.0, FUTURE.asset_id): Decimal("2"), (3.0, FUTURE.asset_id): Decimal("0")}, + ) + assert FUTURE.asset_id not in result.state.portfolio.positions + + +@pytest.mark.parametrize( + ("settlement", "records", "message"), + [ + (_settled(FUTURE.asset_id, "4035", at=4.5), (FUTURE,), "still trades"), + (_settled(SHARE.asset_id, "10"), (FUTURE, SHARE), "does not expire"), + (_settled("0b7c3a52-3e43-4d0e-8f6c-1a9e5d2c7b10", "1"), (FUTURE,), "not declared"), + (_settled(FUTURE.asset_id, "-1"), (FUTURE,), "do not admit"), + ], + ids=["before-expiry", "no-expiry", "undeclared", "negative-price"], +) +def test_a_settlement_that_cannot_be_right_is_refused( + settlement: ScheduledSettlement, records: tuple[InstrumentRecord, ...], message: str +) -> None: + with pytest.raises(RuntimeValidationError, match=message): + BacktestEngine.run( + _config(*records), + _dataset(FUTURE.asset_id, FUTURE_MIDS), + running_strategy_state(STRATEGY, Holder({(2.0, FUTURE.asset_id): Decimal("2")})), + context_factory, + schedule=RunSchedule.of(settlements=(settlement,)), + ) + + +def test_a_perpetual_does_not_expire_and_is_not_settled() -> None: + perpetual = InstrumentRecord( + "BTCPERP", + AssetType.CRYPTO, + "XBIN", + "USD", + economics=_economics("1", SettlementModel.PERPETUAL), + last_trade_at=2.5, + ) + with pytest.raises(RuntimeValidationError, match="only a future or an option"): + _run( + perpetual, + {2.0: "100", 3.0: "101"}, + {}, + settlements=(_settled(perpetual.asset_id, "100", at=3.0),), + ) + + +def test_an_order_still_working_at_settlement_is_cancelled() -> None: + """A limit that never fills before expiry: the settlement cancels it, frees what it held.""" + + class RestsALimit(Holder): + def on_quote(self, context: StrategyContext, event: Any) -> Iterable[Intent]: + if event.quote.timestamp == 4.0: + return ( + Intent( + STRATEGY, + FUTURE.asset_id, + Decimal("1"), + timestamp=4.0, + terms=OrderTerms.limit(Decimal("3000")), + ), + ) + return () + + result = BacktestEngine.run( + _config(FUTURE), + _dataset(FUTURE.asset_id, FUTURE_MIDS), + running_strategy_state(STRATEGY, RestsALimit({})), + context_factory, + schedule=RunSchedule.of(settlements=(_settled(FUTURE.asset_id, "4035"),)), + ) + (order,) = result.orders + assert order.status.name == "CANCELLED" + assert result.state.allocation.reservations.get(str(order.order_id.value)) is None + + +# --------------------------------------------------------------------------- # +# Replay, payloads and the instrument registry +# --------------------------------------------------------------------------- # + +SCHEDULE = RunSchedule.of(settlements=(_settled(FUTURE.asset_id, "4035"),)) +PLAN = {(2.0, FUTURE.asset_id): Decimal("2")} + + +def _replayed(pause: int | None) -> ReplayResult: + config = _config(FUTURE) + dataset = _dataset(FUTURE.asset_id, FUTURE_MIDS) + if pause is None: + result = ReplayBacktest.run( + config, + dataset, + running_strategy_state(STRATEGY, Holder(PLAN)), + context_factory, + schedule=SCHEDULE, + ) + assert isinstance(result, ReplayResult) + return result + paused = ReplayBacktest.run( + config, + dataset, + running_strategy_state(STRATEGY, Holder(PLAN)), + context_factory, + pause_when=lambda progress: progress.records_replayed == pause, + schedule=SCHEDULE, + ) + assert isinstance(paused, PausedReplay) + restored = replay_snapshot.restore( + replay_snapshot.from_primitives(deserialize(serialize(replay_snapshot.capture(paused)))), + RunObjects( + pipeline=RuntimeObjects( + sizing_model=config.pipeline.sizing_model, + simulator=config.pipeline.simulator, + strategies={STRATEGY: Holder(PLAN)}, + instruments=config.pipeline.instruments, + ), + fill_policy=config.fill_policy, + ), + dataset, + SCHEDULE, + ) + resumed = ReplayBacktest.resume(restored, context_factory) + assert isinstance(resumed, ReplayResult) + return resumed + + +def test_the_replay_settles_as_the_backtest_does() -> None: + backtest = _run(FUTURE, FUTURE_MIDS, PLAN, settlements=(_settled(FUTURE.asset_id, "4035"),)) + replay = _replayed(None).backtest + assert (replay.fills, replay.valuation, replay.state.trade_records.to_tuple()) == ( + backtest.fills, + backtest.valuation, + backtest.state.trade_records.to_tuple(), + ) + + +@pytest.mark.parametrize("pause", range(len(FUTURE_MIDS))) +def test_a_replay_paused_anywhere_settles_identically(pause: int) -> None: + assert digest_run(_replayed(pause).backtest) == digest_run(_replayed(None).backtest) + + +def test_the_last_trading_instant_survives_a_registry_snapshot() -> None: + registry = registry_of(FUTURE, SHARE) + payload = deserialize(serialize(capture_registry(registry))) + restored = restore_registry(registry_from_primitives(payload)) + + assert restored == registry + assert restored.record_for(FUTURE.asset_id).last_trade_at == EXPIRY # type: ignore[union-attr] + assert restored.record_for(SHARE.asset_id).last_trade_at is None # type: ignore[union-attr] + + +def test_a_last_trading_instant_must_be_an_instant() -> None: + from alphalab.instrument.exceptions import InstrumentInputError + + for bad in (float("nan"), True, "2026-03-20"): + with pytest.raises(InstrumentInputError, match="last_trade_at"): + replace(FUTURE, last_trade_at=bad) # type: ignore[arg-type] + assert FUTURE.asset_id == replace(FUTURE, last_trade_at=None).asset_id, "not identity" diff --git a/tests/integration/test_execution_algorithm_example.py b/tests/integration/test_execution_algorithm_example.py new file mode 100644 index 00000000..faa76ac3 --- /dev/null +++ b/tests/integration/test_execution_algorithm_example.py @@ -0,0 +1,74 @@ +"""Example 74 measures what it says it measures (v4.1.1, ledger FUT-009). + +``examples/74_evaluate_an_execution_algorithm.py`` executes one decision five +ways over the same bars. These tests hold its figures to the mechanism: the +whole order is cut by the liquidity cap, every algorithm completes the parent in +children, each child pays the run's costs, and the replay and a resumed replay +are the run. +""" + +from __future__ import annotations + +import importlib.util +import sys +from decimal import Decimal +from pathlib import Path +from types import ModuleType + +import pytest + +from alphalab.core.enums import OrderStatus +from alphalab.lifecycle import digest_run + +EXAMPLE = Path(__file__).resolve().parents[2] / "examples" / "74_evaluate_an_execution_algorithm.py" + + +@pytest.fixture(scope="module") +def example() -> ModuleType: + spec = importlib.util.spec_from_file_location("example_74", EXAMPLE) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_the_whole_order_is_cut_by_the_cap_and_every_algorithm_completes( + example: ModuleType, +) -> None: + whole = example.measure("whole", example.run("whole")) + # A quarter of the next bar's volume, and the rest withdrawn. + assert whole.status is OrderStatus.CANCELLED and whole.fills == 1 + assert whole.filled == (example.volume(1) / 4).to_integral_value(rounding="ROUND_DOWN") + for name in ("twap", "vwap", "participation", "iceberg"): + measured = example.measure(name, example.run(name)) + assert measured.status is OrderStatus.FILLED, name + assert measured.filled == example.QUANTITY + assert measured.commission == Decimal("1.00") * measured.fills + assert measured.shortfall_bps is not None + + +def test_every_child_fill_is_capped_by_its_own_bar(example: ModuleType) -> None: + for name in ("twap", "vwap", "participation", "iceberg"): + book = example.run(name).algorithms + assert book is not None + for fill in book.fills: + minute = round((fill.timestamp - example.OPEN) / example.MINUTE) + assert fill.quantity <= example.volume(minute) / 4, (name, minute) + + +def test_the_replay_and_a_resumed_replay_are_the_run(example: ModuleType) -> None: + backtest = example.run("twap") + replay = example.replayed("twap") + assert replay.fills == backtest.fills + for pause in (1, 7, 26): + resumed = example.replayed("twap", pause=pause) + assert digest_run(resumed).result_id == digest_run(replay).result_id + + +def test_the_example_runs_to_its_end( + example: ModuleType, capsys: pytest.CaptureFixture[str] +) -> None: + example.main() + out = capsys.readouterr().out + assert "replay paused at bar 7" in out and "rerun: the same result" in out diff --git a/tests/integration/test_lifecycle_conventions.py b/tests/integration/test_lifecycle_conventions.py new file mode 100644 index 00000000..acf003d1 --- /dev/null +++ b/tests/integration/test_lifecycle_conventions.py @@ -0,0 +1,325 @@ +"""A contract's lifecycle in markets whose conventions differ, none named in the engine (FUT-013). + +Each profile here is **synthetic and declared**: a calendar with its time zone, +sessions and holidays, a currency, a multiplier, a deliverable quantity, an +exercise style and a settlement rule. The time zones are real ones -- +``Asia/Kolkata`` keeps no daylight saving, ``America/New_York`` and +``Europe/London`` change on different dates -- because time-zone arithmetic is +what is under test; nothing here claims to be any exchange's actual rule book. +The last profile is a market AlphaLab has never heard of, trading Sunday to +Thursday with a lunch break, registered by declaring it -- and run through the +same code. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import replace +from datetime import date, time +from decimal import Decimal +from typing import Any + +import pytest + +from alphalab.backtesting import ( + BacktestEngine, + BacktestResult, + RunSchedule, + ScheduledExercise, + ScheduledExpiry, +) +from alphalab.backtesting.dataset import MarketDataset +from alphalab.conventions.economics import InstrumentEconomics, SettlementModel +from alphalab.conventions.lot import LotSpecification +from alphalab.conventions.settlement import SettlementBasis, SettlementRule +from alphalab.core.enums import AssetType +from alphalab.data.calendar import MarketCalendar, SessionWindow +from alphalab.instrument.record import InstrumentRecord +from alphalab.instrument.terms import AutomaticExercise, Deliverable, OptionTerms +from alphalab.market.quote import Quote +from alphalab.options.enums import ExerciseStyle, OptionType +from alphalab.options.expiration import SettlementStyle +from alphalab.portfolio.account import Account +from alphalab.portfolio.corporate_actions import Exercise, OptionExpiry +from alphalab.portfolio.fx import FxRate +from alphalab.runtime.calendars import session_close, settles_at +from alphalab.runtime.exceptions import RuntimeValidationError +from alphalab.runtime.run import RunConfig +from alphalab.strategy.context import StrategyContext +from alphalab.strategy.events import Intent, IntentKind +from alphalab.strategy.protocol import BaseStrategy +from tests.integration.harness import ( + backtest_config, + context_factory, + registry_of, + running_strategy_state, +) + +WEEKDAYS = (0, 1, 2, 3, 4) + + +def calendar( + calendar_id: str, + zone: str, + windows: tuple[tuple[time, time], ...], + days: Iterable[int] = WEEKDAYS, + holidays: Iterable[date] = (), +) -> MarketCalendar: + sessions = tuple(SessionWindow(opens, closes) for opens, closes in windows) + return MarketCalendar(calendar_id, zone, dict.fromkeys(days, sessions), frozenset(holidays)) + + +NO_DST = calendar("SYN-KOLKATA", "Asia/Kolkata", ((time(9, 15), time(15, 30)),)) +NEW_YORK = calendar("SYN-NEWYORK", "America/New_York", ((time(9, 30), time(16, 0)),)) +LONDON = calendar("SYN-LONDON", "Europe/London", ((time(8, 0), time(16, 30)),)) +#: A market AlphaLab has never heard of: Sunday to Thursday, a lunch break, a +#: holiday on a Monday. +UNHEARD_OF = calendar( + "SYN-XSYN", + "Asia/Dubai", + ((time(10, 0), time(12, 0)), (time(13, 0), time(15, 0))), + days=(6, 0, 1, 2, 3), + holidays=(date(2024, 3, 11),), +) +T_PLUS_ONE = SettlementRule(SettlementBasis.TRADING_DAYS, 1) + + +# --------------------------------------------------------------------------- # +# Instants from a market's own calendar +# --------------------------------------------------------------------------- # + + +def test_the_close_is_the_markets_own_across_daylight_saving() -> None: + # New York moved its clocks on 10 March 2024; London on 31 March; Kolkata never. + assert session_close(NEW_YORK, date(2024, 3, 8)) == 1709931600.0 # 21:00 UTC + assert session_close(NEW_YORK, date(2024, 3, 11)) == 1710187200.0 # 20:00 UTC + assert session_close(LONDON, date(2024, 3, 28)) == 1711643400.0 # 16:30 UTC + assert session_close(LONDON, date(2024, 4, 2)) == 1712071800.0 # 15:30 UTC + assert session_close(NO_DST, date(2024, 3, 8)) == 1709892000.0 # 10:00 UTC + assert session_close(NO_DST, date(2024, 4, 2)) - session_close(NO_DST, date(2024, 3, 8)) == ( + 25 * 86400 + ) + # After a lunch break the close is the afternoon's. + assert session_close(UNHEARD_OF, date(2024, 3, 10)) == 1710068400.0 # 15:00 Dubai + + +def test_a_date_the_market_is_shut_is_not_moved_by_a_default() -> None: + with pytest.raises(RuntimeValidationError, match="does not trade on 2024-03-09"): + session_close(NEW_YORK, date(2024, 3, 9)) + with pytest.raises(RuntimeValidationError, match="does not trade on 2024-03-11"): + session_close(UNHEARD_OF, date(2024, 3, 11)) # its holiday + with pytest.raises(RuntimeValidationError, match="does not trade on 2024-03-15"): + session_close(UNHEARD_OF, date(2024, 3, 15)) # a Friday + + +def test_a_settlement_rule_counts_the_markets_own_trading_days() -> None: + # Thursday + 1 trading day: Friday in New York, Sunday in the unheard-of market, + # whose Monday after is a holiday. + assert settles_at(NEW_YORK, T_PLUS_ONE, date(2024, 3, 7)) == 1709908200.0 # Fri 14:30 UTC + assert settles_at(UNHEARD_OF, T_PLUS_ONE, date(2024, 3, 7)) == 1710050400.0 # Sun 10:00 Dubai + assert settles_at(UNHEARD_OF, T_PLUS_ONE, date(2024, 3, 10)) == 1710223200.0 # Tue: Mon shut + calendar_days = SettlementRule(SettlementBasis.CALENDAR_DAYS, 2) + with pytest.raises(RuntimeValidationError, match="does not trade on"): + settles_at(NEW_YORK, calendar_days, date(2024, 3, 7)) # lands on a Saturday + + +# --------------------------------------------------------------------------- # +# One engine, several conventions +# --------------------------------------------------------------------------- # + + +class Buyer(BaseStrategy): + def __init__(self, asset_id: str, at: float, quantity: Decimal) -> None: + self._asset, self._at, self._quantity = asset_id, at, quantity + + def on_quote(self, context: StrategyContext, event: Any) -> Iterable[Intent]: + if event.quote.asset_id != self._asset or event.quote.timestamp != self._at: + return () + return ( + Intent( + "S", + self._asset, + self._quantity, + timestamp=self._at, + kind=IntentKind.TARGET_QUANTITY, + ), + ) + + +def _quote(asset: str, at: float, mid: str, currency: str) -> Quote: + price = Decimal(mid) + return Quote(asset, at, price, price, Decimal("1000"), Decimal("1000"), "SIM", currency) + + +def _config( + base: str, records: Iterable[InstrumentRecord], also: frozenset[str] = frozenset() +) -> RunConfig: + config = backtest_config("S") + pipeline = replace( + config.pipeline, + account=Account(f"ACC-{base}", base, f"{base} book", 1.0), + currency="", + also_settles=also, + instruments=registry_of(*records), + # Two settlement currencies: the budget states which one it is in. + budget=replace(config.pipeline.budget, currency=base if also else ""), + ) + return replace(config, pipeline=pipeline) + + +def _profile( + market: MarketCalendar, + currency: str, + style: ExerciseStyle, + settlement: SettlementStyle, + multiplier: str, + units: str, + expiry_day: date, +) -> tuple[InstrumentRecord, InstrumentRecord, float]: + underlying = InstrumentRecord( + f"U{currency}", AssetType.EQUITY, market.calendar_id[4:], currency + ) + expires = session_close(market, expiry_day) + contract = InstrumentRecord( + f"O{currency}", + AssetType.OPTION, + market.calendar_id[4:], + currency, + economics=InstrumentEconomics( + Decimal(multiplier), + SettlementModel.OPTION_PREMIUM, + lot=LotSpecification(Decimal("1"), Decimal("1")), + ), + last_trade_at=expires, + terms=OptionTerms( + OptionType.CALL, + Decimal("100"), + style, + settlement, + Deliverable(underlying.asset_id, Decimal(units)), + expires_at=expires, + automatic_exercise=AutomaticExercise(Decimal("0")), + ), + ) + return underlying, contract, expires + + +def _run( + base: str, + underlying: InstrumentRecord, + contract: InstrumentRecord, + start: float, + schedule: RunSchedule, + also: frozenset[str] = frozenset(), +) -> BacktestResult: + # The data runs past every lifecycle instant: an input after the last record + # is not applied, because the run could not have acted on it. + after = max(entry.at for entry in schedule.entries) + 3600 + quotes = [ + _quote(contract.asset_id, start, "4", contract.currency), + _quote(underlying.asset_id, start, "100", underlying.currency), + _quote(underlying.asset_id, start + 3600, "103", underlying.currency), + _quote(underlying.asset_id, after, "104", underlying.currency), + ] + return BacktestEngine.run( + _config(base, (underlying, contract), also), + MarketDataset.of("CONV", quotes), + running_strategy_state("S", Buyer(contract.asset_id, start, Decimal("2"))), + context_factory, + schedule=schedule, + ) + + +@pytest.mark.parametrize( + ("market", "currency", "multiplier", "units", "day"), + [ + (NO_DST, "INR", "75", "75", date(2024, 3, 28)), + (NEW_YORK, "USD", "100", "100", date(2024, 3, 15)), + (LONDON, "GBP", "1000", "1000", date(2024, 4, 19)), + (UNHEARD_OF, "AED", "10", "10", date(2024, 3, 14)), + ], + ids=["no-dst-inr", "dst-usd", "dst-gbp", "unheard-of-aed"], +) +def test_a_european_cash_option_settles_in_its_own_currency_and_units( + market: MarketCalendar, currency: str, multiplier: str, units: str, day: date +) -> None: + underlying, contract, expires = _profile( + market, currency, ExerciseStyle.EUROPEAN, SettlementStyle.CASH, multiplier, units, day + ) + start = expires - 3 * 86400 + result = _run( + currency, + underlying, + contract, + start, + RunSchedule.of( + expiries=[ScheduledExpiry(OptionExpiry(contract.asset_id, Decimal("107")), expires)] + ), + ) + paid = 2 * 7 * Decimal(multiplier) + premium = 2 * 4 * Decimal(multiplier) + assert result.state.portfolio.cash.balance(currency) == Decimal("1000000") - premium + paid + assert contract.asset_id not in result.state.portfolio.positions + + +def test_an_american_physical_option_is_exercised_and_delivered_on_its_markets_settlement_day() -> ( + None +): + underlying, contract, _ = _profile( + UNHEARD_OF, + "AED", + ExerciseStyle.AMERICAN, + SettlementStyle.PHYSICAL, + "10", + "10", + date(2024, 3, 14), + ) + # Instructed on Thursday the 7th; delivered T+1 by its calendar, the Sunday. + delivers = settles_at(UNHEARD_OF, T_PLUS_ONE, date(2024, 3, 7)) + start = session_close(UNHEARD_OF, date(2024, 3, 7)) - 7200 + result = _run( + "AED", + underlying, + contract, + start, + RunSchedule.of( + exercises=[ScheduledExercise(Exercise(contract.asset_id, Decimal("2")), delivers)], + ), + ) + position = result.state.portfolio.positions[underlying.asset_id] + assert position.quantity == Decimal("20") and position.average_cost == Decimal("100") + assert result.state.portfolio.cash.balance("AED") == ( + Decimal("1000000") - Decimal("80") - Decimal("2000") + ) + assert position.opened_at == delivers + + +def test_an_option_settling_in_another_currency_is_valued_at_the_rate_in_force() -> None: + underlying, contract, expires = _profile( + LONDON, + "GBP", + ExerciseStyle.EUROPEAN, + SettlementStyle.CASH, + "1000", + "1000", + date(2024, 4, 19), + ) + start = expires - 3 * 86400 + rates = [ + FxRate("GBP", "USD", Decimal("1.25"), start - 60, "desk"), + FxRate("GBP", "USD", Decimal("1.30"), expires - 60, "desk"), + ] + from alphalab.backtesting.schedule import Funding + + schedule = RunSchedule.of( + rates=rates, + fundings=[Funding(Decimal("50000"), "GBP", start - 30)], + expiries=[ScheduledExpiry(OptionExpiry(contract.asset_id, Decimal("107")), expires)], + ) + result = _run("USD", underlying, contract, start, schedule, also=frozenset({"GBP"})) + gbp = result.state.portfolio.cash.balance("GBP") + assert gbp == Decimal("50000") - Decimal("8000") + Decimal("14000") + # The book is reported in dollars at the rate in force at the expiry. + assert result.state.portfolio_snapshots[-1].total_equity == ( + Decimal("1000000") + gbp * Decimal("1.30") + ) diff --git a/tests/integration/test_lifecycle_example.py b/tests/integration/test_lifecycle_example.py new file mode 100644 index 00000000..290c6b56 --- /dev/null +++ b/tests/integration/test_lifecycle_example.py @@ -0,0 +1,87 @@ +"""Example 75 does what it prints: an option's life, figure for figure (v4.1.1, ledger FUT-013). + +Every figure is recomputed here from the example's own price functions and the +contracts' declared terms, so the test fails if the engine and the arithmetic +part company -- not if the example's prose changes. +""" + +from __future__ import annotations + +import importlib.util +import sys +from decimal import Decimal +from pathlib import Path +from types import ModuleType + +import pytest + +from alphalab.lifecycle import digest_run + +EXAMPLE = Path(__file__).resolve().parents[2] / "examples" / "75_exercise_assign_and_deliver.py" + + +@pytest.fixture(scope="module") +def example() -> ModuleType: + spec = importlib.util.spec_from_file_location("example_75", EXAMPLE) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_every_movement_of_cash_and_shares_is_the_contracts(example: ModuleType) -> None: + result = example.run() + last = len(example.SESSIONS) - 1 + # Bought when momentum first turned up -- the fourth session -- and filled at + # the options' bars of that close, in the order the run reaches them. + call_premium = example.call_close(3) * 2 * 100 + put_premium = example.put_close(3) * 1 * 10 + strike_paid = Decimal("100") * 100 * 2 # one call exercised early, one at expiry + dividend = example.DIVIDEND * 100 # on the 100 shares delivered before the ex-date + put_paid = max(Decimal("2000") - example.index_close(last), Decimal("0")) * 10 + expected = example.START_CASH - call_premium - put_premium - strike_paid + dividend + put_paid + portfolio = result.state.portfolio + assert portfolio.cash.balance("GBP") == expected + shares = portfolio.positions[example.STOCK.asset_id] + assert shares.quantity == Decimal("200") and shares.average_cost == Decimal("100") + assert example.CALL.asset_id not in portfolio.positions + assert example.PUT.asset_id not in portfolio.positions + valuation = result.valuation + assert valuation.equity == ( + example.START_CASH + + valuation.realized_pnl + + valuation.unrealized_pnl + - valuation.commission_paid + ) + + +def test_the_early_exercise_takes_effect_at_the_decision_close_before_the_dividend( + example: ModuleType, +) -> None: + scheduled = example.early_exercise() + decision = example.SESSIONS[example.SESSIONS.index(example.EX_DIVIDEND) - 1] + assert scheduled.at == example.session_close(example.MARKET, decision) + result = example.run() + (exercise,) = [r for r in result.state.trade_records if r.trade_id.startswith("EXERCISE")] + assert exercise.trade_id.endswith("pre-dividend") + # The dividend reached the delivered shares: realized P&L holds it. + premiums = example.call_close(3) * 100 * 2 + put_gain = max(Decimal("2000") - example.index_close(len(example.SESSIONS) - 1), Decimal("0")) + assert result.valuation.realized_pnl == ( + -premiums + example.DIVIDEND * 100 + put_gain * 10 - example.put_close(3) * 10 + ) + + +def test_what_the_contracts_cannot_do_is_refused(example: ModuleType) -> None: + found = example.refusals() + assert len(found) == 3 + assert "BERMUDAN" in found[0] and "EUROPEAN" in found[2] + + +def test_the_replay_and_a_resumed_replay_are_the_run(example: ModuleType) -> None: + result = example.run() + replay = example.replayed() + assert replay.fills == result.fills and replay.valuation == result.valuation + for pause in (10, 40, 70): + assert digest_run(example.replayed(pause)).result_id == digest_run(replay).result_id diff --git a/tests/integration/test_model_on_the_execution_path.py b/tests/integration/test_model_on_the_execution_path.py new file mode 100644 index 00000000..78cd637f --- /dev/null +++ b/tests/integration/test_model_on_the_execution_path.py @@ -0,0 +1,203 @@ +"""Research outputs reach the execution path without a second definition or a look-ahead (v4.1.1). + +``examples/73_train_and_trade_a_model.py`` fits a linear model on two +``FeatureDefinition``s computed by the study path, and trades it through the +canonical path with a strategy that evaluates the same definitions on the +closes it has been shown. Ledger FUT-006 (one feature definition, in a study and +in a strategy) and FUT-007 (a trained model on the execution path, evidenced) +are closed by what this file establishes: + +* the strategy's feature values equal the study's at every instant both have; +* every training label was knowable before the boundary, and changing every + price from the boundary on changes neither the model nor any decision before it; +* no order exists before the boundary; +* the fitted parameters are the strategy's declared parameters -- fingerprinted + -- and its state round-trips exactly through a stored, continued run; +* a rerun, in this process and a fresh one, is identical. +""" + +from __future__ import annotations + +import importlib.util +import os +import subprocess +import sys +from dataclasses import replace +from decimal import Decimal +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +from alphalab.api import to_market_dataset +from alphalab.backtesting import BacktestEngine, BacktestResult +from alphalab.common.ids import id_scope +from alphalab.lifecycle import digest_run +from alphalab.persistence import deserialize, serialize +from alphalab.runtime.run import RunEngine +from alphalab.runtime.run_snapshot import RunObjects, capture, from_primitives, restore +from alphalab.runtime.snapshot import RuntimeObjects + +ROOT = Path(__file__).resolve().parents[2] +EXAMPLE = ROOT / "examples" / "73_train_and_trade_a_model.py" + + +@pytest.fixture(scope="module") +def example() -> ModuleType: + spec = importlib.util.spec_from_file_location("example_73", EXAMPLE) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def data(example: ModuleType) -> Any: + return example.dataset() + + +@pytest.fixture(scope="module") +def training(example: ModuleType, data: Any) -> Any: + return example.fit(data) + + +@pytest.fixture(scope="module") +def result(example: ModuleType, data: Any, training: Any) -> BacktestResult: + run: BacktestResult = example.run(data, training) + return run + + +def test_the_strategy_computes_the_studys_features_exactly( + example: ModuleType, data: Any, result: BacktestResult +) -> None: + study = example.study_features(data) + seen = example.instance_of(result).seen + # Every instant past the longer window, both computed, all equal. + assert len(seen) == example.SESSIONS - 9 + assert example.agreement(data, result) == len(seen) + for name in example.FEATURE_NAMES: + assert set(study[name]) >= set(seen) + + +def test_every_training_label_was_known_before_the_boundary( + example: ModuleType, training: Any +) -> None: + day, first = example.DAY, example.FIRST_BAR + assert training.boundary_time == first + example.BOUNDARY * day + last = training.sample_times[-1] + # Its label is the close HORIZON sessions later: before the boundary. + assert last + example.HORIZON * day < training.boundary_time + # The sample one session later -- whose label is the boundary's close -- is purged. + assert last + day not in training.sample_times + assert training.sample_times == tuple(sorted(training.sample_times)) + + +def test_prices_from_the_boundary_on_change_neither_the_model_nor_an_earlier_decision( + example: ModuleType, training: Any, result: BacktestResult +) -> None: + shocked_data = example.dataset(shock=Decimal("25")) + shocked = example.fit(shocked_data) + assert shocked.model == training.model + assert shocked.sample_times == training.sample_times + + other = example.run(shocked_data, shocked) + boundary = training.boundary_time + before = example.instance_of(result).seen + after = example.instance_of(other).seen + assert {at: v for at, v in before.items() if at < boundary} == { + at: v for at, v in after.items() if at < boundary + } + assert [o.created_at for o in other.orders] and min( + o.created_at for o in other.orders + ) >= boundary + + +def test_no_order_is_placed_before_the_boundary(training: Any, result: BacktestResult) -> None: + assert result.orders + assert min(order.created_at for order in result.orders) == training.boundary_time + assert all(fill.filled_at > training.boundary_time for fill in result.fills) + + +def test_the_model_is_the_strategys_declared_parameters(example: ModuleType, training: Any) -> None: + parameters = example.model_parameters(training) + assert parameters["intercept"] == training.model.intercept + for name, coefficient in zip(example.FEATURE_NAMES, training.model.coefficients, strict=True): + assert parameters[f"coef.{name}"] == coefficient + built = example.CLASSES.construct(example.STRATEGY_ID, parameters) + assert built._model == training.model + + +@pytest.mark.parametrize("stop_after", [10, 79, 80, 81, 119]) +def test_a_run_stored_and_continued_with_a_rebuilt_strategy_is_the_same_run( + example: ModuleType, data: Any, training: Any, result: BacktestResult, stop_after: int +) -> None: + config = example.run_config() + records = to_market_dataset(data, example.NORMALIZATION).records + initial = example.running(training) + assert config.seed is not None + with id_scope(config.seed): + state = replace(BacktestEngine.initialize(config, initial), source_id=data.dataset_version) + for record in records[:stop_after]: + state = RunEngine.close_slice(state, example.CONTEXTS, before=record.timestamp) + state, _ = RunEngine.advance(state, record, example.CONTEXTS) + payload = serialize(capture(state)) + + parameters = example.model_parameters(training) + restored = restore( + from_primitives(deserialize(payload)), + RunObjects( + pipeline=RuntimeObjects( + sizing_model=config.pipeline.sizing_model, + simulator=config.pipeline.simulator, + strategies={ + example.STRATEGY_ID: example.CLASSES.construct(example.STRATEGY_ID, parameters) + }, + instruments=example.REGISTRY, + ), + fill_policy=config.fill_policy, + ), + ) + with BacktestEngine.resume(restored): + for record in records[stop_after:]: + restored = RunEngine.close_slice(restored, example.CONTEXTS, before=record.timestamp) + restored, _ = RunEngine.advance(restored, record, example.CONTEXTS) + finished = BacktestResult( + run=RunEngine.finalize(RunEngine.close_slice(restored, example.CONTEXTS)) + ) + assert digest_run(finished).result_id == digest_run(result).result_id + + +def test_a_strategy_built_with_another_model_refuses_the_captured_state( + example: ModuleType, training: Any, result: BacktestResult +) -> None: + captured = example.instance_of(result).capture_state() + other = dict(example.model_parameters(training)) + other["intercept"] = float(other["intercept"]) + 1.0 + built = example.CLASSES.construct(example.STRATEGY_ID, other) + with pytest.raises(ValueError, match="not the one"): + built.restore_state(deserialize(serialize(captured)), 1) + + +def test_a_fresh_interpreter_produces_the_same_result(result: BacktestResult) -> None: + script = ( + "import importlib.util, sys\n" + f"spec = importlib.util.spec_from_file_location('ex73', {str(EXAMPLE)!r})\n" + "module = importlib.util.module_from_spec(spec)\n" + "sys.modules['ex73'] = module\n" + "spec.loader.exec_module(module)\n" + "from alphalab.lifecycle import digest_run\n" + "data = module.dataset()\n" + "print(digest_run(module.run(data, module.fit(data))).result_id)\n" + ) + for seed in ("0", "777"): + completed = subprocess.run( + [sys.executable, "-W", "error", "-c", script], + capture_output=True, + text=True, + check=True, + env={**os.environ, "PYTHONHASHSEED": seed, "PYTHONPATH": str(ROOT)}, + cwd=ROOT, + ) + assert completed.stdout.strip() == digest_run(result).result_id diff --git a/tests/integration/test_multi_asset_composition.py b/tests/integration/test_multi_asset_composition.py new file mode 100644 index 00000000..9248e4ae --- /dev/null +++ b/tests/integration/test_multi_asset_composition.py @@ -0,0 +1,625 @@ +"""A researcher-defined multi-asset strategy over prices, sentiment and macro data (v4.1.1). + +The acceptance scenario of v4.1.1. ``examples/72_compose_a_multi_asset_strategy.py`` +builds it from the public API: an equity and an index future from two vendor +files read as one input, social-media posts and a US CPI series with revisions +read with the instant each became available, a strategy written against the +protocol that combines them, execution costs, a risk gate that refuses, and the +lineage of the result. This file imports that example and holds it to each +property the scenario exists to establish: + +1. every eligible input is consumed; +2. late information is usable only once it was available -- proved from the + strategy's own log of what it knew, and from runs in which the late values + are changed, against a control that leaks and is caught; +3. out-of-order, missing, duplicate, unreadable and undated data each have an + explicit, tested behaviour; +4. the future is booked as a future, in contracts, with its declared multiplier; +5. every order, fill and change to the book goes through the canonical path; +6. refusals and failures are in the result, not hidden; +7. the result names its datasets, strategy, configuration and engine; +8. a rerun -- in this process or a fresh one -- is identical; +9. a replay, a replay paused at any boundary, written to a payload, restored + and resumed, and a backtest stored and continued are each the same run. + +None of it says the strategy makes money. The prices are synthetic. +""" + +from __future__ import annotations + +import importlib.util +import os +import subprocess +import sys +from collections.abc import Iterator +from dataclasses import replace +from decimal import Decimal +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +from alphalab.alt_data.streaming import ObservationDelivery +from alphalab.api import combine_datasets, combined_market_dataset +from alphalab.backtesting import BacktestEngine, BacktestResult, RunSchedule, replay_snapshot +from alphalab.backtesting.replay import PausedReplay, ReplayBacktest +from alphalab.data.exceptions import DataValidationError +from alphalab.instrument.record import InstrumentRecord +from alphalab.instrument.registry import InstrumentRegistry, register_instruments +from alphalab.lifecycle import ( + LifecycleInputError, + digest_run, + external_requirements, + manifest_for_run, + running_build, + running_engine, + verify_manifest, +) +from alphalab.persistence import deserialize, serialize +from alphalab.persistence.exceptions import StateDecodeError +from alphalab.portfolio.nav import NAVCalculator + +ROOT = Path(__file__).resolve().parents[2] +EXAMPLE = ROOT / "examples" / "72_compose_a_multi_asset_strategy.py" + + +@pytest.fixture(scope="module") +def example() -> ModuleType: + spec = importlib.util.spec_from_file_location("example_72", EXAMPLE) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def data(example: ModuleType) -> Any: + return example.inputs() + + +@pytest.fixture(scope="module") +def result(example: ModuleType, data: Any) -> BacktestResult: + run: BacktestResult = example.run(data) + return run + + +def _decisions(example: ModuleType, run: BacktestResult) -> list[Any]: + return list(example.instance_of(run).decisions) + + +def _choices(decision: Any) -> tuple[Any, ...]: + """What a decision chose and on what reading -- not the ids of what it was told, + which name the set they came from and so differ between two sets of inputs.""" + + return (decision.at, decision.equity_target, decision.future_target, decision.cpi) + + +def _orders(run: BacktestResult) -> list[tuple[float, str, str, Decimal]]: + return sorted( + (order.created_at, order.asset_id, order.side.value, order.quantity) for order in run.orders + ) + + +# --------------------------------------------------------------------------- # +# 1. Every eligible input is consumed +# --------------------------------------------------------------------------- # + + +def test_every_price_of_both_instruments_is_read(example: ModuleType, result: Any) -> None: + market = combined_market_dataset(example.prices()) + by_asset: dict[str, int] = {} + for record in market.records: + by_asset[record.payload.asset_id] = by_asset.get(record.payload.asset_id, 0) + 1 + + sessions = len(example.SESSIONS) + assert by_asset == {example.ACME.asset_id: sessions, example.IDX.asset_id: sessions - 1} + assert result.records_processed == len(market) == 2 * sessions - 1 + assert len(example.instance_of(result)._closes) == sessions + + +def test_every_observation_knowable_inside_the_data_is_delivered_and_seen( + example: ModuleType, data: Any, result: BacktestResult +) -> None: + last = result.run.last_record_timestamp + assert last is not None + deliveries = data.deliveries() + # Both sets merged into one stream, ordered by when each record became knowable. + eligible = sorted( + (delivery for delivery in deliveries if delivery.known_at <= last), + key=lambda delivery: delivery.order_key, + ) + + # May's CPI is published after the data ends, and only it. + assert [d.record.period.label for d in deliveries if d.known_at > last] == ["2024-05"] + assert result.run.observations_delivered == len(eligible) == len(deliveries) - 1 + seen = example.instance_of(result)._seen + assert [delivery for _, delivery in seen] == [d.delivery_id for d in eligible] + + +def test_both_instruments_are_traded_on_the_combined_signal( + example: ModuleType, result: BacktestResult +) -> None: + traded = {fill.asset_id for fill in result.fills} + assert traded == {example.ACME.asset_id, example.IDX.asset_id} + hedges = [d for d in _decisions(example, result) if d.future_target not in (None, Decimal(0))] + assert hedges, "the inflation hedge was taken" + assert all(d.cpi is not None and Decimal(d.cpi[2]) >= Decimal("3.5") for d in hedges) + + +# --------------------------------------------------------------------------- # +# 2. Late information is usable only once it was available +# --------------------------------------------------------------------------- # + + +def test_no_decision_saw_anything_before_it_was_knowable( + example: ModuleType, data: Any, result: BacktestResult +) -> None: + known = {delivery.delivery_id: delivery.known_at for delivery in data.deliveries()} + for decision in _decisions(example, result): + for at, delivery in decision.seen: + assert at == known[delivery], "delivered at its knowledge instant, exactly" + assert at <= decision.at + + +def test_the_late_post_reaches_the_strategy_when_it_was_collected_not_when_written( + example: ModuleType, data: Any, result: BacktestResult +) -> None: + written, collected = (example.ny(text) for text in example.LATE_POST) + sentiment, _ = data.schedules() + (late,) = [d for d in sentiment if d.record.stamp.observed_at == written] + assert late.known_at == collected + + decisions = _decisions(example, result) + before = [d for d in decisions if d.at < collected] + after = [d for d in decisions if d.at >= collected] + assert before and after + assert all(late.delivery_id not in {delivery for _, delivery in d.seen} for d in before) + assert all(late.delivery_id in {delivery for _, delivery in d.seen} for d in after) + + +def _vintage_known_at(example: ModuleType, at: float) -> tuple[str, int, str] | None: + """The CPI reading a decision at ``at`` may use, worked out from the release rows alone.""" + + released: dict[str, tuple[float, int, str]] = {} + for row in example.macro_rows(): + published = example.ny(row["released"]) + if published > at: + continue + period, vintage = row["period"], int(row["vintage"]) + if period not in released or vintage > released[period][1]: + released[period] = (example.ny(row["period_end"]), vintage, row["value"]) + if not released: + return None + period = max(released, key=lambda name: released[name][0]) + return period, released[period][1], released[period][2] + + +def test_every_decision_used_the_cpi_vintage_published_by_then( + example: ModuleType, result: BacktestResult +) -> None: + decisions = _decisions(example, result) + for decision in decisions: + expected = _vintage_known_at(example, decision.at) + assert decision.cpi is not None and expected is not None + assert (decision.cpi[0], decision.cpi[1], Decimal(decision.cpi[2])) == ( + expected[0], + expected[1], + Decimal(expected[2]), + ) + # Each vintage was in use at some point: the first release, the revision, April. + used = {(d.cpi[0], d.cpi[1]) for d in decisions if d.cpi is not None} + assert {("2024-02", 0), ("2024-03", 0), ("2024-03", 1), ("2024-04", 0)} <= used + + +def test_changing_late_values_changes_nothing_before_they_were_known( + example: ModuleType, result: BacktestResult +) -> None: + """The counterfactual: the late post and March's revision rewritten, nothing else.""" + + altered = example.run(example.inputs(late_score="-0.95", march_revision="3.90")) + earliest = example.ny(example.LATE_POST[1]) # the revision is published later still + + assert [o for o in _orders(altered) if o[0] < earliest] == [ + o for o in _orders(result) if o[0] < earliest + ] + pairs = list(zip(_decisions(example, result), _decisions(example, altered), strict=True)) + for ours, theirs in pairs: + if ours.at < earliest: + assert _choices(ours) == _choices(theirs) + changed = [ours.at for ours, theirs in pairs if _choices(ours) != _choices(theirs)] + assert changed and min(changed) >= earliest, "used once known, never before" + + +def test_a_schedule_that_leaks_is_caught_by_the_same_check( + example: ModuleType, data: Any, result: BacktestResult +) -> None: + """The control: deliver every record when it was *observed*, as a naive join would. + + The late post then moves decisions two weeks before anyone could have read + it -- so the counterfactual above would have failed had the engine leaked. + """ + + def leaky(inputs: Any) -> list[ObservationDelivery[Any]]: + return [ + replace(delivery, known_at=delivery.record.stamp.observed_at) + for delivery in inputs.deliveries() + ] + + def run_with(inputs: Any) -> BacktestResult: + run: BacktestResult = BacktestEngine.run( + example.run_config(), + combined_market_dataset(inputs.prices), + example.running(inputs), + example.CONTEXTS, + leaky(inputs), + ) + return run + + plain = run_with(data) + altered = run_with(example.inputs(late_score="-0.95")) + earliest = example.ny(example.LATE_POST[1]) + pairs = zip(_decisions(example, plain), _decisions(example, altered), strict=True) + leaked = [ours.at for ours, theirs in pairs if _choices(ours) != _choices(theirs)] + assert leaked and min(leaked) < earliest + + +# --------------------------------------------------------------------------- # +# 3. Out-of-order, missing, duplicate, unreadable and undated data +# --------------------------------------------------------------------------- # + + +def test_rows_out_of_order_are_delivered_in_the_order_they_became_knowable( + example: ModuleType, data: Any +) -> None: + rows = example.sentiment_rows() + assert rows[0]["posted_at"] == example.LATE_POST[0], "the file leads with the late post" + sentiment, macro = data.schedules() + for schedule in (sentiment, macro): + keys = [delivery.order_key for delivery in schedule] + assert keys == sorted(keys) + + +def test_a_repeated_row_and_an_unreadable_one_are_rejected_by_line(data: Any) -> None: + rejected = { + rejection.line_number: rejection.findings[0].detail for rejection in data.sentiment.rejected + } + assert len(rejected) == 2 + assert any("repeats record" in detail for detail in rejected.values()) + assert any("'n/a' is not a number" in detail for detail in rejected.values()) + + +def test_a_post_with_no_stated_availability_is_counted_and_never_delivered( + example: ModuleType, data: Any, result: BacktestResult +) -> None: + sentiment, _ = data.schedules() + assert sentiment.unverified == 1 + undated = example.ny("2024-04-15 11:00") + assert all(d.record.stamp.observed_at != undated for d in sentiment) + for decision in _decisions(example, result): + assert all(not delivery.endswith("UNKNOWN") for _, delivery in decision.seen) + + +def test_a_missing_future_bar_leaves_the_future_alone_rather_than_hedging_at_a_stale_price( + example: ModuleType, result: BacktestResult +) -> None: + gap = [ + d + for d in _decisions(example, result) + if example.XNYS.local_datetime(d.at).date() == example.FUTURE_GAP + ] + assert gap, "the strategy still decided on the equity that day" + assert all(d.future_target is None for d in gap) + assert not [ + order + for order in result.orders + if order.asset_id == example.IDX.asset_id + and example.XNYS.local_datetime(order.created_at).date() == example.FUTURE_GAP + ] + + +def test_two_datasets_carrying_one_instruments_price_at_one_instant_are_refused( + example: ModuleType, +) -> None: + rows = example.bar_rows(example.ACME.symbol, example.EQUITY_CLOSES, example.EQUITY_CLOSE) + first = example.ingest_prices("EX72-ACME", rows, example.DataAssetClass.EQUITY) + second = example.ingest_prices("EX72-ACME-COPY", rows[:3], example.DataAssetClass.EQUITY) + combined = combine_datasets([(first, example.EQUITY_POLICY), (second, example.EQUITY_POLICY)]) + + with pytest.raises(DataValidationError, match="choose one source"): + combined_market_dataset(combined) + with pytest.raises(DataValidationError, match="given twice"): + combine_datasets([(first, example.EQUITY_POLICY), (first, example.EQUITY_POLICY)]) + with pytest.raises(DataValidationError, match="two or more"): + combine_datasets([(first, example.EQUITY_POLICY)]) + + +# --------------------------------------------------------------------------- # +# 4. The future is booked as a future +# --------------------------------------------------------------------------- # + + +def test_the_future_is_held_in_whole_contracts_and_its_notional_is_never_paid( + example: ModuleType, result: BacktestResult +) -> None: + contracts = [fill for fill in result.fills if fill.asset_id == example.IDX.asset_id] + assert contracts + assert all(fill.quantity == fill.quantity.to_integral_value() for fill in contracts) + position = result.state.portfolio.positions[example.IDX.asset_id] + assert position.multiplier == Decimal("50") + assert not position.pays_notional + # A contract's notional is many thousands; the account never paid it. + notional = sum(fill.quantity * fill.price * 50 for fill in contracts) + paid = example.START_CASH - result.valuation.cash + assert abs(paid) < notional + + +def test_the_book_is_one_currency_and_its_identity_holds( + example: ModuleType, result: BacktestResult +) -> None: + portfolio = result.state.portfolio + assert portfolio.settlement_currencies == ("USD",) + valuation = result.valuation + assert valuation.equity == ( + example.START_CASH + + valuation.realized_pnl + + valuation.unrealized_pnl + - valuation.commission_paid + ) + assert valuation.equity == NAVCalculator.calculate(portfolio.cash, portfolio.positions, "USD") + + +def test_a_strategy_whose_hedge_instrument_declares_no_multiplier_fails_visibly( + example: ModuleType, data: Any +) -> None: + bare = InstrumentRecord( + "IDXM4", example.AssetType.FUTURE, "XCME", "USD", aliases={example.PROVIDER: "IDXM4"} + ) + registry = register_instruments(InstrumentRegistry(), (example.ACME, bare)) + config = example.run_config() + config = replace(config, pipeline=replace(config.pipeline, instruments=registry)) + run = BacktestEngine.run( + config, + combined_market_dataset(data.prices), + example.running(data), + example.CONTEXTS, + data.deliveries(), + ) + (failure,) = run.strategy_failures + assert "declares no multiplier" in failure.error + assert not [fill for fill in run.fills if fill.asset_id == example.IDX.asset_id] + + +# --------------------------------------------------------------------------- # +# 5-6. The canonical path, and refusals in the result +# --------------------------------------------------------------------------- # + + +def test_every_fill_belongs_to_an_order_and_comes_after_the_decision( + result: BacktestResult, +) -> None: + orders = {str(order.order_id.value): order for order in result.orders} + assert result.fills + for fill in result.fills: + order = orders[fill.order_id] + assert fill.filled_at > order.created_at, "never at the price that decided it" + assert result.execution_assumptions.optimistic == () + + +def test_orders_the_risk_gate_refuses_are_in_the_result_and_never_filled( + example: ModuleType, result: BacktestResult +) -> None: + refused = [d for d in result.state.risk.history if not d.approved] + assert refused + for decision in refused: + (violation,) = decision.violations + assert violation.current_value > violation.allowed_value == Decimal("1000") + filled = {fill.order_id for fill in result.fills} + assert not {decision.order_id for decision in refused} & filled + assert result.strategy_failures == () + + +def test_costs_are_charged_on_every_fill(result: BacktestResult) -> None: + paid = result.state.portfolio.commission_paid["USD"] + assert paid >= Decimal(len(result.fills)) * Decimal("1.00"), "at least the per-trade fee" + + +# --------------------------------------------------------------------------- # +# 7. Lineage +# --------------------------------------------------------------------------- # + + +def test_the_manifest_names_the_combined_datasets_and_checks_them( + example: ModuleType, data: Any, result: BacktestResult +) -> None: + manifest = example.manifest(data, result) + provenance = data.prices.require_provenance() + + assert result.dataset_id == manifest.dataset_version == provenance.dataset_version + assert manifest.dataset_content_hash == provenance.content_hash + assert [version for version, _ in provenance.parts] == [ + dataset.require_provenance().dataset_version for dataset, _ in data.prices.parts + ] + assert verify_manifest(manifest) + assert manifest.engine == running_engine() + + # Each part is named, in order, for a rerun to be given back (FUT-005). + assert manifest.dataset_parts == provenance.parts + details = [r.detail for r in external_requirements(manifest)] + for version, content in provenance.parts: + assert any(version in d and content in d for d in details) + # Parts the combined identity was not derived from do not verify. + swapped = replace(manifest, dataset_parts=tuple(reversed(manifest.dataset_parts))) + assert not verify_manifest(swapped) + + reversed_parts = combine_datasets(tuple(reversed(data.prices.parts))) + assert reversed_parts.dataset_version != data.prices.dataset_version + with pytest.raises(LifecycleInputError, match="dataset supplied"): + manifest_for_run( + result, + reversed_parts, + example.fingerprint(data), + running_engine(), + build=running_build(), + ) + + +def test_a_dataset_stating_parts_it_was_not_derived_from_is_refused( + example: ModuleType, data: Any, result: BacktestResult +) -> None: + provenance = data.prices.require_provenance() + + class Lying: + dataset_version = provenance.dataset_version + + def require_provenance(self) -> Any: + return replace(provenance, parts=tuple(reversed(provenance.parts))) + + with pytest.raises(LifecycleInputError, match="were not derived from"): + manifest_for_run( + result, Lying(), example.fingerprint(data), running_engine(), build=running_build() + ) + + +def test_the_manifest_names_the_schedule_and_refuses_one_the_run_was_not_given( + example: ModuleType, data: Any, result: BacktestResult +) -> None: + manifest = example.manifest(data, result) + assert manifest.schedule_digest == data.schedule().digest + assert any( + manifest.schedule_digest in requirement.detail + for requirement in external_requirements(manifest) + ) + # Another information set is another manifest. + other = example.inputs(late_score="-0.95") + assert other.schedule().digest != data.schedule().digest + # A schedule missing what the run delivered last is not this run's. + last = result.run.last_observation + assert last is not None + partial = RunSchedule.of( + observations=[d for d in data.deliveries() if d.delivery_id != last[1]] + ) + with pytest.raises(LifecycleInputError, match="does not hold"): + manifest_for_run( + result, + data.prices, + example.fingerprint(data), + running_engine(), + build=running_build(), + schedule=partial, + ) + + +def test_the_information_sets_are_part_of_the_strategys_identity( + example: ModuleType, data: Any +) -> None: + other = example.inputs(late_score="-0.95") + assert other.sentiment.observations.version != data.sentiment.observations.version + assert other.macro.observations.version == data.macro.observations.version + assert example.fingerprint(other).fingerprint != example.fingerprint(data).fingerprint + assert data.parameters()["sentiment_set"] == data.sentiment.observations.version + + +# --------------------------------------------------------------------------- # +# 8. Reruns +# --------------------------------------------------------------------------- # + + +def test_a_rerun_is_identical(example: ModuleType, result: BacktestResult) -> None: + assert digest_run(example.run(example.inputs())) == digest_run(result) + + +def test_a_fresh_interpreter_under_another_hash_seed_produces_the_same_result( + result: BacktestResult, +) -> None: + script = ( + "import importlib.util, sys\n" + f"spec = importlib.util.spec_from_file_location('ex72', {str(EXAMPLE)!r})\n" + "module = importlib.util.module_from_spec(spec)\n" + "sys.modules['ex72'] = module\n" + "spec.loader.exec_module(module)\n" + "from alphalab.lifecycle import digest_run\n" + "print(digest_run(module.run(module.inputs())).result_id)\n" + ) + for seed in ("0", "12345"): + environment = {**os.environ, "PYTHONHASHSEED": seed, "PYTHONPATH": str(ROOT)} + completed = subprocess.run( + [sys.executable, "-W", "error", "-c", script], + capture_output=True, + text=True, + check=True, + env=environment, + cwd=ROOT, + ) + assert completed.stdout.strip() == digest_run(result).result_id + + +# --------------------------------------------------------------------------- # +# 9. Replay, pause and resume, stop and continue +# --------------------------------------------------------------------------- # + + +def _economics(run: BacktestResult) -> tuple[Any, ...]: + return ( + _orders(run), + run.fills, + run.valuation, + run.run.observations_delivered, + run.run.last_observation, + ) + + +def test_the_replay_is_the_backtest(example: ModuleType, data: Any, result: BacktestResult) -> None: + replay = example.replayed(data) + assert _economics(replay.backtest) == _economics(result) + assert example.instance_of(replay.backtest).decisions == _decisions(example, result) + + +def _boundaries(example: ModuleType) -> Iterator[int]: + total = len(combined_market_dataset(example.prices())) + yield from range(0, total, 1) + + +def test_a_replay_paused_at_any_boundary_resumes_into_the_same_result( + example: ModuleType, data: Any +) -> None: + reference = digest_run(example.replayed(data).backtest) + for boundary in _boundaries(example): + assert digest_run(example.replayed(data, pause_at=boundary).backtest) == reference + + +def test_a_backtest_stored_at_any_record_and_continued_is_the_same_result( + example: ModuleType, data: Any, result: BacktestResult +) -> None: + reference = digest_run(result).result_id + for stop_after in _boundaries(example): + assert digest_run(example.continued(data, stop_after)).result_id == reference + + +def test_a_paused_replay_is_held_to_the_schedule_it_was_paused_on( + example: ModuleType, data: Any +) -> None: + """Every pause above went through a payload; this one is resumed with another schedule.""" + + paused = ReplayBacktest.run( + example.run_config(), + combined_market_dataset(data.prices), + example.running(data), + example.CONTEXTS, + pause_when=lambda progress: progress.records_replayed == 20, + schedule=data.schedule(), + ) + assert isinstance(paused, PausedReplay) + snapshot = replay_snapshot.from_primitives( + deserialize(serialize(replay_snapshot.capture(paused))) + ) + assert snapshot.schedule_digest == data.schedule().digest + altered = example.inputs(late_score="-0.95") + with pytest.raises(StateDecodeError, match="other inputs"): + replay_snapshot.restore( + snapshot, + example.fresh_objects(data), + combined_market_dataset(data.prices), + altered.schedule(), + ) diff --git a/tests/integration/test_option_lifecycle.py b/tests/integration/test_option_lifecycle.py new file mode 100644 index 00000000..1875ffd5 --- /dev/null +++ b/tests/integration/test_option_lifecycle.py @@ -0,0 +1,817 @@ +"""Options exercised, assigned and expired, and futures delivered, by their terms (FUT-013). + +Every scenario runs through :meth:`BacktestEngine.run` -- a strategy trades the +contract, the run's schedule carries the exercise, the assignment or the +expiry, and the portfolio, the allocation ledger, the trade records and the +valuation are checked against the arithmetic the contract's terms produce. +Cash is free and fills are immediate, so every figure is the contract's own. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import replace +from decimal import Decimal +from typing import Any + +import pytest + +from alphalab.backtesting import ( + BacktestEngine, + BacktestResult, + PausedReplay, + ReplayBacktest, + RunSchedule, + ScheduledAssignment, + ScheduledExercise, + ScheduledExpiry, + ScheduledSettlement, + ScheduleValidationError, + replay_snapshot, +) +from alphalab.backtesting.dataset import MarketDataset +from alphalab.conventions.economics import InstrumentEconomics, SettlementModel +from alphalab.conventions.lot import LotSpecification +from alphalab.core.enums import AssetType +from alphalab.instrument.exceptions import InstrumentInputError +from alphalab.instrument.record import InstrumentRecord +from alphalab.instrument.snapshot import capture as capture_registry +from alphalab.instrument.snapshot import from_primitives as registry_from_primitives +from alphalab.instrument.snapshot import restore as restore_registry +from alphalab.instrument.terms import ( + AutomaticExercise, + Deliverable, + FutureTerms, + OptionTerms, + terms_from_primitives, + terms_to_primitives, +) +from alphalab.lifecycle import digest_run +from alphalab.options.contract import OptionContract +from alphalab.options.enums import ExerciseStyle, OptionType +from alphalab.options.expiration import ExpirationPolicy, SettlementStyle, resolve_expiration +from alphalab.persistence import deserialize, serialize +from alphalab.portfolio.corporate_actions import ( + Assignment, + ContractSettlement, + Exercise, + OptionExpiry, +) +from alphalab.runtime.exceptions import RuntimeValidationError +from alphalab.runtime.run import RunConfig +from alphalab.runtime.run_snapshot import RunObjects +from alphalab.runtime.snapshot import RuntimeObjects +from alphalab.strategy.context import StrategyContext +from alphalab.strategy.events import Intent, IntentKind +from alphalab.strategy.protocol import BaseStrategy +from alphalab.strategy.runtime import create_runtime, register_strategy +from alphalab.strategy.state import RuntimeState +from alphalab.strategy.supervisor import RuntimeSupervisor +from tests.integration.harness import ( + START_CASH, + backtest_config, + context_factory, + quote, + registry_of, +) + +LAST_TRADE = 6.0 +EXPIRES = 6.5 +SHARE = InstrumentRecord("ACME", AssetType.EQUITY, "XNAS", "USD") +OTHER_CCY_SHARE = InstrumentRecord("ACME", AssetType.EQUITY, "XETR", "EUR") +ONE = LotSpecification(Decimal("1"), Decimal("1")) +ANY_IN_THE_MONEY = AutomaticExercise(Decimal("0")) + + +def option( + symbol: str, + option_type: OptionType, + style: ExerciseStyle, + settlement: SettlementStyle, + *, + strike: str = "100", + units: str = "100", + multiplier: str = "100", + rule: AutomaticExercise | None = ANY_IN_THE_MONEY, + deliverable: InstrumentRecord = SHARE, + exercisable_from: float | None = None, + currency: str = "USD", +) -> InstrumentRecord: + return InstrumentRecord( + symbol, + AssetType.OPTION, + "XOPT", + currency, + economics=InstrumentEconomics(Decimal(multiplier), SettlementModel.OPTION_PREMIUM, lot=ONE), + last_trade_at=LAST_TRADE, + terms=OptionTerms( + option_type=option_type, + strike=Decimal(strike), + exercise_style=style, + settlement=settlement, + deliverable=Deliverable(deliverable.asset_id, Decimal(units)), + expires_at=EXPIRES, + automatic_exercise=rule, + exercisable_from=exercisable_from, + ), + ) + + +AM_CALL = option("ACMEC100", OptionType.CALL, ExerciseStyle.AMERICAN, SettlementStyle.PHYSICAL) +AM_PUT = option("ACMEP100", OptionType.PUT, ExerciseStyle.AMERICAN, SettlementStyle.PHYSICAL) +EU_CASH_CALL = option("IDXC100", OptionType.CALL, ExerciseStyle.EUROPEAN, SettlementStyle.CASH) +EU_CASH_PUT = option("IDXP100", OptionType.PUT, ExerciseStyle.EUROPEAN, SettlementStyle.CASH) + + +class Holder(BaseStrategy): + """Holds a target in an asset from chosen instants.""" + + def __init__(self, strategy_id: str, plan: dict[tuple[float, str], Decimal]) -> None: + self._id = strategy_id + self._plan = plan + + def on_quote(self, context: StrategyContext, event: Any) -> Iterable[Intent]: + target = self._plan.get((event.quote.timestamp, event.quote.asset_id)) + if target is None: + return () + return ( + Intent( + self._id, + event.quote.asset_id, + target, + timestamp=event.quote.timestamp, + kind=IntentKind.TARGET_QUANTITY, + ), + ) + + +def runtime(plans: dict[str, dict[tuple[float, str], Decimal]]) -> RuntimeState: + state = create_runtime() + for sid, plan in plans.items(): + state = register_strategy(state, sid, Holder(sid, plan)) + running = {} + for sid, entry in state.strategies.items(): + entry, _ = RuntimeSupervisor.configure(entry, {}, 1.0) + entry, _ = RuntimeSupervisor.initialize(entry, 1.1) + entry, _ = RuntimeSupervisor.subscribe(entry, frozenset({"*"}), 1.2) + running[sid], _ = RuntimeSupervisor.start(entry, 1.3) + return replace(state, strategies=running) + + +def config( + records: Iterable[InstrumentRecord], + strategies: Iterable[str] = ("S",), + *, + cash: Decimal = START_CASH, + shorting: bool = True, +) -> RunConfig: + ids = tuple(strategies) + base = backtest_config(ids[0], starting_cash=cash) + pipeline = replace( + base.pipeline, + instruments=registry_of(*records), + budget=replace(base.pipeline.budget, strategy_budgets=dict.fromkeys(ids, cash)), + allocation_constraints=replace( + base.pipeline.allocation_constraints, allow_shorting=shorting + ), + ) + return replace(base, pipeline=pipeline) + + +def data(mids: dict[str, dict[float, str]]) -> MarketDataset: + quotes = [ + quote(asset, at, Decimal(mid)) + for asset, series in mids.items() + for at, mid in series.items() + ] + return MarketDataset.of("LIFE", sorted(quotes, key=lambda q: (q.timestamp, q.asset_id))) + + +def series(*values: str, start: float = 2.0) -> dict[float, str]: + return {start + n: v for n, v in enumerate(values)} + + +#: The option quotes until it stops trading; the share all along. +SHARE_MIDS = series("100", "104", "108", "110", "112", "112", "111", "113") + + +def run( + contract: InstrumentRecord, + plans: dict[str, dict[tuple[float, str], Decimal]], + schedule: RunSchedule, + *, + option_mids: dict[float, str] | None = None, + extra: Iterable[InstrumentRecord] = (), + cash: Decimal = START_CASH, + shorting: bool = True, +) -> BacktestResult: + mids = { + SHARE.asset_id: SHARE_MIDS, + contract.asset_id: option_mids or series("5", "6", "8", "9"), + } + return BacktestEngine.run( + config((contract, SHARE, *extra), plans, cash=cash, shorting=shorting), + data(mids), + runtime(plans), + context_factory, + schedule=schedule, + ) + + +def hold(contract: InstrumentRecord, contracts: str, sid: str = "S") -> dict[str, Any]: + return {sid: {(2.0, contract.asset_id): Decimal(contracts)}} + + +def expiry(contract: InstrumentRecord, value: str, at: float = EXPIRES) -> RunSchedule: + return RunSchedule.of( + expiries=[ScheduledExpiry(OptionExpiry(contract.asset_id, Decimal(value)), at)] + ) + + +def cash(result: BacktestResult) -> Decimal: + return result.state.portfolio.cash.balance("USD") + + +def held(result: BacktestResult, record: InstrumentRecord) -> Decimal: + position = result.state.portfolio.positions.get(record.asset_id) + return Decimal("0") if position is None else position.quantity + + +def identity(result: BacktestResult) -> None: + valuation = result.valuation + assert valuation.equity == ( + START_CASH + valuation.realized_pnl + valuation.unrealized_pnl - valuation.commission_paid + ) + + +# --------------------------------------------------------------------------- # +# Cash settlement at expiry: calls and puts, long and short, in, at and out +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + ("contract", "contracts", "value", "paid"), + [ + (EU_CASH_CALL, "2", "112", "2400"), # 2 x 12 x 100 + (EU_CASH_CALL, "2", "100", "0"), # at the money: never exercised + (EU_CASH_CALL, "2", "93", "0"), # out of the money + (EU_CASH_PUT, "2", "93", "1400"), # 2 x 7 x 100 + (EU_CASH_PUT, "2", "112", "0"), + (EU_CASH_CALL, "-2", "112", "-2400"), # the writer pays + (EU_CASH_PUT, "-3", "93", "-2100"), + ], + ids=["call-itm", "call-atm", "call-otm", "put-itm", "put-otm", "short-call", "short-put"], +) +def test_a_cash_settled_option_pays_its_intrinsic_value_at_expiry( + contract: InstrumentRecord, contracts: str, value: str, paid: str +) -> None: + result = run(contract, hold(contract, contracts), expiry(contract, value)) + premium = Decimal(contracts) * Decimal("5") * Decimal("100") + assert held(result, contract) == 0 + assert cash(result) == START_CASH - premium + Decimal(paid) + assert result.valuation.realized_pnl == Decimal(paid) - premium + identity(result) + # The engine's figure is the research model's, contract for contract. + research = resolve_expiration( + OptionContract( + SHARE.asset_id, + Decimal("100"), + EXPIRES, + contract.terms.option_type, # type: ignore[union-attr] + ExerciseStyle.EUROPEAN, + 100, + ), + Decimal(contracts), + Decimal(value), + ExpirationPolicy(SettlementStyle.CASH, True), + ) + assert research.cash_flow == Decimal(paid) + + +def test_an_option_with_no_exercise_rule_expires_unexercised_unless_instructed() -> None: + manual = option( + "IDXC100M", OptionType.CALL, ExerciseStyle.EUROPEAN, SettlementStyle.CASH, rule=None + ) + lapsed = run(manual, hold(manual, "2"), expiry(manual, "112")) + assert cash(lapsed) == START_CASH - Decimal("1000") + instructed = run( + manual, + hold(manual, "2"), + RunSchedule.of( + exercises=[ + ScheduledExercise(Exercise(manual.asset_id, Decimal("2"), Decimal("112")), EXPIRES) + ], + expiries=[ScheduledExpiry(OptionExpiry(manual.asset_id, Decimal("112")), EXPIRES)], + ), + ) + # The exercise at the expiry instant applies before the expiry resolves the rest. + assert cash(instructed) == START_CASH - Decimal("1000") + Decimal("2400") + + +def test_an_exercise_threshold_is_the_contracts_and_not_a_default() -> None: + strict = option( + "IDXC100T", + OptionType.CALL, + ExerciseStyle.EUROPEAN, + SettlementStyle.CASH, + rule=AutomaticExercise(Decimal("0.50")), + ) + below = run(strict, hold(strict, "1"), expiry(strict, "100.25")) + above = run(strict, hold(strict, "1"), expiry(strict, "100.75")) + assert cash(below) == START_CASH - Decimal("500") + assert cash(above) == START_CASH - Decimal("500") + Decimal("75") + + +# --------------------------------------------------------------------------- # +# Physical settlement: the deliverable moves against the strike +# --------------------------------------------------------------------------- # + + +def test_an_exercised_call_takes_the_shares_and_pays_the_strike() -> None: + result = run(AM_CALL, hold(AM_CALL, "2"), expiry(AM_CALL, "112")) + assert held(result, AM_CALL) == 0 + assert held(result, SHARE) == Decimal("200") + assert cash(result) == START_CASH - Decimal("1000") - Decimal("20000") + # The shares are marked at the market's last price for them, not the strike -- + # at the delivery itself, before any later price reaches them. + share = result.state.portfolio.positions[SHARE.asset_id] + assert share.average_cost == Decimal("100") and share.market_price == Decimal("113") + (at_delivery,) = [p for p in result.state.portfolio_snapshots if p.timestamp == EXPIRES] + last_mid = Decimal(SHARE_MIDS[LAST_TRADE]) + assert at_delivery.total_equity == ( + START_CASH - Decimal("1000") - Decimal("20000") + Decimal("200") * last_mid + ) + identity(result) + + +def test_an_exercised_put_delivers_shares_held_and_receives_the_strike() -> None: + plans = {"S": {(2.0, AM_PUT.asset_id): Decimal("1"), (2.0, SHARE.asset_id): Decimal("100")}} + result = run(AM_PUT, plans, expiry(AM_PUT, "93")) + assert held(result, SHARE) == 0 and held(result, AM_PUT) == 0 + assert cash(result) == START_CASH - Decimal("500") - Decimal("10000") + Decimal("10000") + identity(result) + + +def test_a_written_call_assigned_at_expiry_delivers_the_covering_shares() -> None: + plans = {"S": {(2.0, AM_CALL.asset_id): Decimal("-1"), (2.0, SHARE.asset_id): Decimal("100")}} + result = run(AM_CALL, plans, expiry(AM_CALL, "112")) + assert held(result, SHARE) == 0 and held(result, AM_CALL) == 0 + # Premium received, shares bought at 100, delivered at the strike of 100. + assert cash(result) == START_CASH + Decimal("500") + assert result.valuation.realized_pnl == Decimal("500") + identity(result) + + +def test_an_american_option_is_exercised_early_in_part_and_the_rest_at_expiry() -> None: + schedule = RunSchedule.of( + exercises=[ + ScheduledExercise(Exercise(AM_CALL.asset_id, Decimal("1"), reference="early"), 4.0) + ], + expiries=[ScheduledExpiry(OptionExpiry(AM_CALL.asset_id, Decimal("112")), EXPIRES)], + ) + result = run(AM_CALL, hold(AM_CALL, "3"), schedule) + early = [ + r + for r in result.state.trade_records + if r.trade_id.startswith(("EXERCISE", "DELIVERY-EXERCISE")) + ] + assert [r.asset_id for r in early] == [AM_CALL.asset_id, SHARE.asset_id] + late = [r for r in result.state.trade_records if "EXPIRY" in r.trade_id] + assert [r.asset_id for r in late] == [AM_CALL.asset_id, SHARE.asset_id] + assert held(result, SHARE) == Decimal("300") and held(result, AM_CALL) == 0 + assert cash(result) == START_CASH - Decimal("1500") - Decimal("30000") + identity(result) + + +def test_an_early_assignment_is_an_input_and_delivers_at_its_instant() -> None: + plans = {"S": {(2.0, AM_PUT.asset_id): Decimal("-2")}} + schedule = RunSchedule.of( + assignments=[ScheduledAssignment(Assignment(AM_PUT.asset_id, Decimal("1")), 3.0)], + expiries=[ScheduledExpiry(OptionExpiry(AM_PUT.asset_id, Decimal("112")), EXPIRES)], + ) + result = run(AM_PUT, plans, schedule) + # Assigned one put: took 100 shares and paid 100 each; the other expired worthless. + assert held(result, SHARE) == Decimal("100") and held(result, AM_PUT) == 0 + assert cash(result) == START_CASH + Decimal("1000") - Decimal("10000") + identity(result) + + +def test_an_adjusted_contract_delivers_its_declared_units_against_its_multiplier() -> None: + adjusted = option( + "ACMEC100A", + OptionType.CALL, + ExerciseStyle.AMERICAN, + SettlementStyle.PHYSICAL, + units="150", + strike="100", + ) + result = run(adjusted, hold(adjusted, "1"), expiry(adjusted, "80")) + # 150 shares at 80 are worth 12,000 against a strike consideration of 10,000. + assert held(result, SHARE) == Decimal("150") + assert cash(result) == START_CASH - Decimal("500") - Decimal("10000") + cash_adjusted = option( + "IDXC100A", OptionType.CALL, ExerciseStyle.EUROPEAN, SettlementStyle.CASH, units="150" + ) + paid = run(cash_adjusted, hold(cash_adjusted, "1"), expiry(cash_adjusted, "80")) + assert cash(paid) == START_CASH - Decimal("500") + Decimal("2000") + + +def test_contract_units_and_multiplier_are_the_contracts_own() -> None: + small = option( + "MINIC100", + OptionType.CALL, + ExerciseStyle.EUROPEAN, + SettlementStyle.CASH, + units="25", + multiplier="25", + ) + result = run(small, hold(small, "4"), expiry(small, "112")) + assert cash(result) == START_CASH - Decimal("4") * Decimal("5") * 25 + Decimal("4") * 12 * 25 + + +# --------------------------------------------------------------------------- # +# Windows: American any time, European only at expiry +# --------------------------------------------------------------------------- # + + +def _exercise( + contract: InstrumentRecord, at: float, count: str = "1", value: str | None = None +) -> RunSchedule: + return RunSchedule.of( + exercises=[ + ScheduledExercise( + Exercise( + contract.asset_id, Decimal(count), None if value is None else Decimal(value) + ), + at, + ) + ] + ) + + +def test_a_european_option_cannot_be_exercised_before_its_window() -> None: + with pytest.raises(RuntimeValidationError, match=r"EUROPEAN: it may be exercised from 6\.0"): + run(EU_CASH_CALL, hold(EU_CASH_CALL, "1"), _exercise(EU_CASH_CALL, 4.0, value="112")) + + +def test_no_option_is_exercised_after_it_expires() -> None: + with pytest.raises(RuntimeValidationError, match=r"not at 7\.0"): + run(AM_CALL, hold(AM_CALL, "1"), _exercise(AM_CALL, 7.0)) + + +def test_an_american_option_respects_a_declared_first_exercise_instant() -> None: + later = option( + "ACMEC100L", + OptionType.CALL, + ExerciseStyle.AMERICAN, + SettlementStyle.PHYSICAL, + exercisable_from=4.0, + ) + with pytest.raises(RuntimeValidationError, match=r"from 4\.0"): + run(later, hold(later, "1"), _exercise(later, 3.0)) + assert held(run(later, hold(later, "1"), _exercise(later, 4.0)), SHARE) == Decimal("100") + + +# --------------------------------------------------------------------------- # +# Refusals: what the book holds, what the instruction carries, what can be delivered +# --------------------------------------------------------------------------- # + + +def test_more_contracts_than_held_cannot_be_exercised_or_assigned() -> None: + with pytest.raises(RuntimeValidationError, match="only a long position is exercised"): + run(AM_CALL, hold(AM_CALL, "1"), _exercise(AM_CALL, 4.0, "2")) + assign = RunSchedule.of( + assignments=[ScheduledAssignment(Assignment(AM_CALL.asset_id, Decimal("1")), 4.0)] + ) + with pytest.raises(RuntimeValidationError, match="only a short position is assigned"): + run(AM_CALL, hold(AM_CALL, "1"), assign) + + +def test_a_cash_exercise_needs_its_settlement_value_and_a_physical_one_refuses_one() -> None: + am_cash = option("IDXC100X", OptionType.CALL, ExerciseStyle.AMERICAN, SettlementStyle.CASH) + with pytest.raises(RuntimeValidationError, match="was not supplied"): + run(am_cash, hold(am_cash, "1"), _exercise(am_cash, 4.0)) + with pytest.raises(RuntimeValidationError, match="read by nothing"): + run(AM_CALL, hold(AM_CALL, "1"), _exercise(AM_CALL, 4.0, value="108")) + + +def test_a_delivery_the_book_cannot_pay_for_is_refused() -> None: + with pytest.raises(RuntimeValidationError, match="no financing of a delivery"): + run(AM_CALL, hold(AM_CALL, "2"), expiry(AM_CALL, "112"), cash=Decimal("5000")) + + +def test_a_delivery_that_would_leave_the_book_short_is_refused_when_shorting_is_not_allowed() -> ( + None +): + with pytest.raises(RuntimeValidationError, match="does not allow a short position"): + run(AM_PUT, hold(AM_PUT, "1"), expiry(AM_PUT, "93"), shorting=False) + + +def test_a_deliverable_the_registry_does_not_declare_or_another_currency_is_refused() -> None: + elsewhere = InstrumentRecord("ZZZ", AssetType.EQUITY, "XNAS", "USD") + orphan = option( + "ZZZC100", + OptionType.CALL, + ExerciseStyle.AMERICAN, + SettlementStyle.PHYSICAL, + deliverable=elsewhere, + ) + with pytest.raises(RuntimeValidationError, match="does not declare"): + run(orphan, hold(orphan, "1"), _exercise(orphan, 4.0)) + euro = option( + "EURC100", + OptionType.CALL, + ExerciseStyle.AMERICAN, + SettlementStyle.PHYSICAL, + deliverable=OTHER_CCY_SHARE, + ) + with pytest.raises(RuntimeValidationError, match="two currencies"): + run(euro, hold(euro, "1"), _exercise(euro, 4.0), extra=(OTHER_CCY_SHARE,)) + + +def test_an_option_with_declared_terms_is_not_settled_at_a_stated_price() -> None: + settle = RunSchedule.of( + settlements=[ + ScheduledSettlement(ContractSettlement(EU_CASH_CALL.asset_id, Decimal("12")), EXPIRES) + ] + ) + with pytest.raises(RuntimeValidationError, match="give an OptionExpiry"): + run(EU_CASH_CALL, hold(EU_CASH_CALL, "1"), settle) + + +def test_an_option_without_terms_cannot_be_exercised() -> None: + bare = replace(AM_CALL, terms=None) + with pytest.raises(RuntimeValidationError, match="declares no OptionTerms"): + run(bare, hold(bare, "1"), _exercise(bare, 4.0)) + + +def test_an_expired_option_still_held_fails_the_run() -> None: + with pytest.raises(RuntimeValidationError, match="past their last trading instant"): + run(AM_CALL, hold(AM_CALL, "1"), RunSchedule()) + + +def test_one_instruction_twice_is_refused_and_a_second_expiry_changes_nothing() -> None: + twice = ScheduledExercise(Exercise(AM_CALL.asset_id, Decimal("1")), 4.0) + with pytest.raises(ScheduleValidationError, match="Two exercise entries"): + RunSchedule.of(exercises=[twice, twice]) + again = RunSchedule.of( + expiries=[ + ScheduledExpiry(OptionExpiry(EU_CASH_CALL.asset_id, Decimal("112")), EXPIRES), + ScheduledExpiry(OptionExpiry(EU_CASH_CALL.asset_id, Decimal("112")), 7.0), + ] + ) + once = run(EU_CASH_CALL, hold(EU_CASH_CALL, "2"), expiry(EU_CASH_CALL, "112")) + assert cash(run(EU_CASH_CALL, hold(EU_CASH_CALL, "2"), again)) == cash(once) + + +@pytest.mark.parametrize( + ("build", "message"), + [ + (lambda: replace(AM_CALL, asset_type=AssetType.EQUITY), "OptionTerms are a option"), + (lambda: replace(AM_CALL, economics=None), "and not economics"), + (lambda: replace(AM_CALL, last_trade_at=None), "no last trading instant"), + (lambda: replace(AM_CALL, last_trade_at=7.0), "exercise cannot end before trading"), + ( + lambda: option( + "X", + OptionType.CALL, + ExerciseStyle.EUROPEAN, + SettlementStyle.CASH, + exercisable_from=1.0, + ), + "American contract's term", + ), + (lambda: FutureTerms(SettlementStyle.PHYSICAL, None), "names its deliverable"), + (lambda: AutomaticExercise(Decimal("-1")), "zero or more"), + (lambda: Deliverable(SHARE.asset_id, Decimal("0")), "positive finite"), + ], + ids=[ + "wrong-type", + "no-economics", + "no-last-trade", + "expires-early", + "european-from", + "future-no-deliverable", + "negative-threshold", + "no-units", + ], +) +def test_terms_that_contradict_the_contract_are_refused_at_declaration( + build: Any, message: str +) -> None: + with pytest.raises(InstrumentInputError, match=message): + build() + + +# --------------------------------------------------------------------------- # +# Attribution: each strategy's share of the contract, and of the delivery +# --------------------------------------------------------------------------- # + + +def test_a_partial_exercise_is_attributed_to_each_strategy_by_its_share() -> None: + plans = { + "A": {(2.0, AM_CALL.asset_id): Decimal("2")}, + "B": {(2.0, AM_CALL.asset_id): Decimal("2")}, + } + schedule = RunSchedule.of( + exercises=[ScheduledExercise(Exercise(AM_CALL.asset_id, Decimal("3")), 4.0)], + expiries=[ScheduledExpiry(OptionExpiry(AM_CALL.asset_id, Decimal("90")), EXPIRES)], + ) + result = run(AM_CALL, plans, schedule) + positions = result.state.allocation.strategy_positions + assert positions is not None + # Three of four by largest remainder, the tie to the earlier strategy. + # The last contract, B's, expired out of the money. + assert AM_CALL.asset_id not in positions["A"] and AM_CALL.asset_id not in positions["B"] + assert positions["A"][SHARE.asset_id] == Decimal("200") + assert positions["B"][SHARE.asset_id] == Decimal("100") + (delivery,) = [r for r in result.state.trade_records if r.trade_id.startswith("DELIVERY")] + assert {c.strategy_id: c.quantity for c in delivery.contributions} == { + "A": Decimal("200"), + "B": Decimal("100"), + } + + +def test_part_of_a_book_whose_strategies_hold_opposite_sides_is_not_attributed() -> None: + plans = { + "A": {(2.0, AM_CALL.asset_id): Decimal("3")}, + "B": {(2.0, AM_CALL.asset_id): Decimal("-1")}, + } + with pytest.raises(RuntimeValidationError, match="opposite sides"): + run(AM_CALL, plans, _exercise(AM_CALL, 4.0, "1")) + + +# --------------------------------------------------------------------------- # +# Futures delivered at their final price +# --------------------------------------------------------------------------- # + +DELIVERED_FUTURE = InstrumentRecord( + "ACMEF", + AssetType.FUTURE, + "XFUT", + "USD", + economics=InstrumentEconomics(Decimal("50"), SettlementModel.FUTURES_VARIATION, lot=ONE), + last_trade_at=LAST_TRADE, + terms=FutureTerms(SettlementStyle.PHYSICAL, Deliverable(SHARE.asset_id, Decimal("50"))), +) + + +def test_a_physically_settled_future_delivers_against_its_final_price() -> None: + settle = RunSchedule.of( + settlements=[ + ScheduledSettlement( + ContractSettlement(DELIVERED_FUTURE.asset_id, Decimal("104")), EXPIRES + ) + ] + ) + result = run( + DELIVERED_FUTURE, + {"S": {(2.0, DELIVERED_FUTURE.asset_id): Decimal("2")}}, + settle, + option_mids=series("100", "101", "102", "103"), + ) + assert held(result, DELIVERED_FUTURE) == 0 + assert held(result, SHARE) == Decimal("100") + # Variation: 2 x 50 x (104 - 100); delivery: 100 shares at 104. + assert cash(result) == START_CASH + Decimal("400") - Decimal("10400") + share = result.state.portfolio.positions[SHARE.asset_id] + assert share.average_cost == Decimal("104") + identity(result) + + +def test_a_cash_settled_future_with_terms_delivers_nothing() -> None: + cash_future = replace( + DELIVERED_FUTURE, symbol="ACMEFC", terms=FutureTerms(SettlementStyle.CASH, None) + ) + settle = RunSchedule.of( + settlements=[ + ScheduledSettlement(ContractSettlement(cash_future.asset_id, Decimal("104")), EXPIRES) + ] + ) + result = run( + cash_future, + {"S": {(2.0, cash_future.asset_id): Decimal("2")}}, + settle, + option_mids=series("100", "101", "102", "103"), + ) + assert held(result, SHARE) == 0 and cash(result) == START_CASH + Decimal("400") + + +# --------------------------------------------------------------------------- # +# Replay, pause and restore; the terms in a registry payload +# --------------------------------------------------------------------------- # + +LIFECYCLE = RunSchedule.of( + exercises=[ScheduledExercise(Exercise(AM_CALL.asset_id, Decimal("1")), 4.0)], + expiries=[ScheduledExpiry(OptionExpiry(AM_CALL.asset_id, Decimal("112")), EXPIRES)], +) + + +def _replay(pause: int | None = None) -> Any: + plans = hold(AM_CALL, "2") + mids = {SHARE.asset_id: SHARE_MIDS, AM_CALL.asset_id: series("5", "6", "8", "9")} + kwargs: dict[str, Any] = {"schedule": LIFECYCLE} + if pause is not None: + kwargs["pause_when"] = lambda progress: progress.records_replayed == pause + return ReplayBacktest.run( + config((AM_CALL, SHARE), plans), data(mids), runtime(plans), context_factory, **kwargs + ) + + +def test_the_replay_exercises_and_delivers_as_the_backtest_does() -> None: + backtest = run(AM_CALL, hold(AM_CALL, "2"), LIFECYCLE) + replayed = _replay() + assert replayed.backtest.fills == backtest.fills + assert replayed.backtest.valuation == backtest.valuation + assert list(replayed.backtest.state.trade_records) == list(backtest.state.trade_records) + + +@pytest.mark.parametrize("pause", range(1, 12)) +def test_a_replay_paused_around_an_exercise_resumes_from_its_payload(pause: int) -> None: + uninterrupted = _replay() + paused = _replay(pause) + assert isinstance(paused, PausedReplay) + objects = RunObjects( + pipeline=RuntimeObjects( + sizing_model=paused.run.config.pipeline.sizing_model, + simulator=paused.run.config.pipeline.simulator, + strategies={"S": Holder("S", hold(AM_CALL, "2")["S"])}, + instruments=registry_of(AM_CALL, SHARE), + ), + fill_policy=paused.run.config.fill_policy, + ) + mids = {SHARE.asset_id: SHARE_MIDS, AM_CALL.asset_id: series("5", "6", "8", "9")} + restored = replay_snapshot.restore( + replay_snapshot.from_primitives(deserialize(serialize(replay_snapshot.capture(paused)))), + objects, + data(mids), + LIFECYCLE, + ) + finished = ReplayBacktest.resume(restored, context_factory) + assert digest_run(finished.backtest).result_id == digest_run(uninterrupted.backtest).result_id + + +def test_terms_round_trip_through_a_registry_payload() -> None: + registry = registry_of(AM_CALL, EU_CASH_PUT, DELIVERED_FUTURE, SHARE) + payload = deserialize(serialize(capture_registry(registry))) + restored = restore_registry(registry_from_primitives(payload)) + for record in (AM_CALL, EU_CASH_PUT, DELIVERED_FUTURE): + assert restored.instruments[record.asset_id].terms == record.terms + assert restored.instruments[record.asset_id].asset_id == record.asset_id + assert terms_from_primitives(terms_to_primitives(AM_CALL.terms)) == AM_CALL.terms + with pytest.raises(InstrumentInputError, match="kind 'swap'"): + terms_from_primitives({"kind": "swap"}) + + +def test_terms_do_not_change_what_an_instrument_is() -> None: + assert replace(AM_CALL, terms=None).asset_id == AM_CALL.asset_id + assert AM_CALL.canonical_key == replace(AM_CALL, terms=None).canonical_key + + +def test_an_exercise_style_the_terms_cannot_state_is_refused_not_approximated() -> None: + with pytest.raises(InstrumentInputError, match="exercise_style is an ExerciseStyle"): + terms = AM_CALL.terms + assert isinstance(terms, OptionTerms) + replace(terms, exercise_style="BERMUDAN") # type: ignore[arg-type] + + +def test_an_option_on_a_future_delivers_a_futures_position_at_the_strike() -> None: + underlying = replace(DELIVERED_FUTURE, symbol="ACMEFU", terms=None, last_trade_at=None) + on_future = option( + "ACMEFC100", + OptionType.CALL, + ExerciseStyle.AMERICAN, + SettlementStyle.PHYSICAL, + units="1", + multiplier="50", + deliverable=underlying, + ) + result = run(on_future, hold(on_future, "2"), expiry(on_future, "112"), extra=(underlying,)) + future = result.state.portfolio.positions[underlying.asset_id] + # Two futures at the strike; a future pays no notional, so only the premium moved. + assert future.quantity == Decimal("2") and future.average_cost == Decimal("100") + assert cash(result) == START_CASH - Decimal("2") * Decimal("5") * Decimal("50") + identity(result) + + +def test_a_cash_option_on_an_undeclared_index_is_not_valued_by_an_assumed_multiplier() -> None: + index = InstrumentRecord("IDX", AssetType.INDEX, "XIDX", "USD") + on_index = option( + "IDXC100U", OptionType.CALL, ExerciseStyle.EUROPEAN, SettlementStyle.CASH, deliverable=index + ) + with pytest.raises(RuntimeValidationError, match="what one unit of it is worth"): + run(on_index, hold(on_index, "1"), expiry(on_index, "112")) + declared = run(on_index, hold(on_index, "1"), expiry(on_index, "112"), extra=(index,)) + assert cash(declared) == START_CASH - Decimal("500") + Decimal("1200") + + +def test_an_index_is_settled_against_and_never_traded() -> None: + index = InstrumentRecord("IDX", AssetType.INDEX, "XIDX", "USD") + result = BacktestEngine.run( + config((index,)), + data({index.asset_id: series("100", "101")}), + runtime({"S": {(2.0, index.asset_id): Decimal("10")}}), + context_factory, + ) + assert not tuple(result.state.oms.orders.orders()) + assert index.asset_id not in result.state.portfolio.positions diff --git a/tests/integration/test_scheduled_inputs.py b/tests/integration/test_scheduled_inputs.py new file mode 100644 index 00000000..60913e58 --- /dev/null +++ b/tests/integration/test_scheduled_inputs.py @@ -0,0 +1,466 @@ +"""Every timed input on the historical drivers, applied at its instant (v4.1.1, ledger FUT-004). + +Until v4.1.1 :meth:`BacktestEngine.run` took records and observations only: a +book settling two currencies, an ``on_timer`` strategy, a dividend or a split +had to be stepped by hand through :class:`~alphalab.runtime.run.RunEngine`. +A :class:`~alphalab.backtesting.schedule.RunSchedule` now carries them, and the +backtest and the replay apply each at its instant -- a rate, a funding, a cash +flow and a split *before* the records of their instant, a timer *after* them -- +with the rate table the schedule builds converting every step. + +Each property is checked against an oracle that does not use the schedule: a +loop written against ``RunEngine`` with the table in force stated by hand, or +the arithmetic itself. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import replace +from decimal import Decimal +from typing import Any + +import pytest + +from alphalab.backtesting import ( + BacktestEngine, + BacktestResult, + Funding, + PausedReplay, + ReplayBacktest, + ReplayResult, + RunSchedule, + ScheduleCursor, + ScheduledCashFlow, + ScheduledDistribution, + ScheduledFundingRate, + ScheduledSplit, + ScheduleValidationError, + replay_snapshot, +) +from alphalab.backtesting.dataset import MarketDataset +from alphalab.common.ids import id_scope +from alphalab.conventions.economics import InstrumentEconomics, SettlementModel +from alphalab.core.enums import AssetType +from alphalab.instrument.record import InstrumentRecord +from alphalab.lifecycle import digest_run +from alphalab.persistence import deserialize, serialize +from alphalab.portfolio.corporate_actions import CashFlow, CashFlowKind, Split +from alphalab.portfolio.fx import FxRate, FxRates, MissingRateError +from alphalab.runtime.run import RunConfig, RunEngine +from alphalab.runtime.run_snapshot import RunObjects +from alphalab.runtime.snapshot import RuntimeObjects +from alphalab.strategy.context import StrategyContext +from alphalab.strategy.events import Intent, IntentKind, TimerEvent +from alphalab.strategy.protocol import BaseStrategy +from tests.integration.harness import ( + START_CASH, + backtest_config, + context_factory, + quote, + registry_of, + running_strategy_state, +) + +STRATEGY = "SCHEDULED" +SAP = InstrumentRecord("SAPX", AssetType.EQUITY, "XETR", "EUR") +ACME = InstrumentRecord("ACME", AssetType.EQUITY, "XNAS", "USD") +EURUSD = ( + FxRate("EUR", "USD", Decimal("1.10"), 1.0, "ECB"), + FxRate("EUR", "USD", Decimal("1.20"), 4.0, "ECB"), +) +EUR_CASH = Decimal("50000") + + +class Planner(BaseStrategy): + """Holds a target quantity of each asset at chosen instants, and buys on each timer.""" + + def __init__(self, plan: dict[tuple[float, str], Decimal], timer_buy: str = "") -> None: + self._plan = plan + self._timer_buy = timer_buy + self.timers: list[float] = [] + + def on_quote(self, context: StrategyContext, event: Any) -> Iterable[Intent]: + key = (event.quote.timestamp, event.quote.asset_id) + target = self._plan.get(key) + if target is None: + return () + return ( + Intent( + STRATEGY, + event.quote.asset_id, + target, + timestamp=event.quote.timestamp, + kind=IntentKind.TARGET_QUANTITY, + ), + ) + + def on_timer(self, context: StrategyContext, event: TimerEvent) -> Iterable[Intent]: + self.timers.append(event.timestamp) + if not self._timer_buy: + return () + return (Intent(STRATEGY, self._timer_buy, Decimal("5"), timestamp=event.timestamp),) + + +def _config(*records: InstrumentRecord, two_currencies: bool = False) -> RunConfig: + base = backtest_config(STRATEGY) + pipeline = replace(base.pipeline, instruments=registry_of(*records)) + if two_currencies: + pipeline = replace( + pipeline, + currency="USD", + also_settles=frozenset({"EUR"}), + budget=pipeline.budget.in_currency("USD"), + ) + return replace(base, pipeline=pipeline) + + +def _quotes(asset_id: str, mids: dict[float, str]) -> list[Any]: + return [quote(asset_id, at, Decimal(mid)) for at, mid in mids.items()] + + +def _strategies(strategy: Planner) -> Any: + return running_strategy_state(STRATEGY, strategy, frozenset({"quotes", "timers"})) + + +def _instance(result: BacktestResult) -> Planner: + instance = result.state.strategy.strategies[STRATEGY].instance + assert isinstance(instance, Planner) + return instance + + +# --------------------------------------------------------------------------- # +# Two currencies: funding, rates, conversion at the rate in force +# --------------------------------------------------------------------------- # + +SAP_MIDS = {2.0: "100", 3.0: "100", 4.0: "100", 5.0: "100", 6.0: "110"} +EUR_PLAN = {(3.0, SAP.asset_id): Decimal("10")} + + +def _eur_run(inputs: RunSchedule) -> BacktestResult: + return BacktestEngine.run( + _config(SAP, two_currencies=True), + MarketDataset.of("EUR", _quotes(SAP.asset_id, SAP_MIDS)), + _strategies(Planner(dict(EUR_PLAN))), + context_factory, + schedule=inputs, + ) + + +EUR_SCHEDULE = RunSchedule.of(rates=EURUSD, fundings=(Funding(EUR_CASH, "EUR", 1.5),)) + + +def test_a_two_currency_book_is_funded_and_traded_through_the_driver() -> None: + result = _eur_run(EUR_SCHEDULE) + + (fill,) = result.fills + assert fill.asset_id == SAP.asset_id and fill.quantity == Decimal("10") + portfolio = result.state.portfolio + assert portfolio.settlement_currencies == ("EUR", "USD") + assert portfolio.positions[SAP.asset_id].currency == "EUR" + assert portfolio.cash.balance("EUR") == EUR_CASH - Decimal("1000") + assert portfolio.cash.balance("USD") == START_CASH + + +def test_each_step_is_valued_at_the_rate_in_force_at_it_and_never_a_later_one() -> None: + """1.10 until the 4.0 quote, 1.20 from it -- a rate quoted at t converts at t.""" + + result = _eur_run(EUR_SCHEDULE) + by_time = {snapshot.timestamp: snapshot.total_equity for snapshot in result.equity_curve} + eur_value = {2.0: EUR_CASH, 3.0: EUR_CASH, 4.0: EUR_CASH, 5.0: EUR_CASH} + eur_value[6.0] = EUR_CASH - Decimal("1000") + Decimal("1100") + for at, rate in ((2.0, "1.10"), (3.0, "1.10"), (4.0, "1.20"), (5.0, "1.20"), (6.0, "1.20")): + assert by_time[at] == START_CASH + eur_value[at] * Decimal(rate), at + + +def test_the_driver_equals_a_loop_that_states_every_rate_by_hand() -> None: + """The oracle: RunEngine stepped with the table in force written out per record.""" + + config = _config(SAP, two_currencies=True) + records = MarketDataset.of("EUR", _quotes(SAP.asset_id, SAP_MIDS)).records + first, second = EURUSD + table = {2.0: FxRates.of([first]), 3.0: FxRates.of([first])} + table |= {at: FxRates.of([second]) for at in (4.0, 5.0, 6.0)} + initial = _strategies(Planner(dict(EUR_PLAN))) + assert config.seed is not None + with id_scope(config.seed): + state = replace(RunEngine.initialize(config, initial), source_id="EUR") + state = RunEngine.fund(state, EUR_CASH, "EUR", 1.5, FxRates.of([first])) + for record in records: + rates = table[record.timestamp] + state = RunEngine.close_slice(state, context_factory, rates, before=record.timestamp) + state, _ = RunEngine.advance(state, record, context_factory, rates=rates) + by_hand = BacktestResult( + run=RunEngine.finalize(RunEngine.close_slice(state, context_factory)) + ) + + assert digest_run(_eur_run(EUR_SCHEDULE)) == digest_run(by_hand) + + +def test_funding_a_second_currency_with_no_rate_yet_quoted_is_refused() -> None: + """The book becomes mixed at 1.5 and nothing says what EUR is worth: refused, not guessed.""" + + late = RunSchedule.of( + rates=(replace(EURUSD[0], as_of=2.5),), fundings=(Funding(EUR_CASH, "EUR", 1.5),) + ) + with pytest.raises(MissingRateError): + _eur_run(late) + + +def test_two_rates_for_one_pair_at_one_instant_are_refused() -> None: + clash = replace(EURUSD[0], rate=Decimal("1.11"), source="OTHER") + with pytest.raises(ScheduleValidationError, match="rate"): + RunSchedule.of(rates=(EURUSD[0], clash)) + + +# --------------------------------------------------------------------------- # +# Timers, a dividend and a split, each at its place in its instant +# --------------------------------------------------------------------------- # + +ACME_MIDS = {2.0: "50", 3.0: "50", 4.0: "51", 5.0: "52", 6.0: "26", 7.0: "27"} +TIMERS: tuple[TimerEvent, ...] = ( + TimerEvent("T-1", 3.5, timer_id="t1", schedule_type="once"), + TimerEvent("T-2", 5.0, timer_id="t2", schedule_type="once"), +) +DIVIDEND = ScheduledCashFlow( + CashFlow(CashFlowKind.DIVIDEND, Decimal("2.50"), "USD", ACME.asset_id), 5.0 +) +SPLIT = ScheduledSplit(Split(ACME.asset_id, Decimal("2")), 6.0) +ACME_SCHEDULE = RunSchedule.of(timers=TIMERS, cash_flows=(DIVIDEND,), splits=(SPLIT,)) + + +def _acme_run(inputs: RunSchedule = ACME_SCHEDULE) -> BacktestResult: + return BacktestEngine.run( + _config(ACME), + MarketDataset.of("ACME", _quotes(ACME.asset_id, ACME_MIDS)), + _strategies(Planner({}, timer_buy=ACME.asset_id)), + context_factory, + schedule=inputs, + ) + + +def test_a_timer_fires_after_its_instants_records_and_its_order_fills_at_the_next_price() -> None: + result = _acme_run() + + assert _instance(result).timers == [3.5, 5.0] + (fill,) = result.fills + assert (fill.filled_at, fill.price) == (4.0, Decimal("51")) + # The timer at 5.0 fires after the quote at 5.0, so its order rests to 6.0 -- + # where the split, applied before the records of 6.0, cancels it as a venue + # cancels open orders at a corporate action. The strategy would ask again. + rested = [order for order in result.orders if order.created_at == 5.0] + assert [order.status.name for order in rested] == ["CANCELLED"] + + +def test_a_dividend_and_a_split_take_effect_before_the_records_of_their_instant() -> None: + result = _acme_run() + portfolio = result.state.portfolio + + # The dividend at 5.0 is the 2.50 the holding was paid, booked as stated. + assert portfolio.realized_pnl["USD"] == Decimal("2.50") + # The split at 6.0 doubles the 5 held, before the quote of 6.0 is marked. + position = portfolio.positions[ACME.asset_id] + assert position.quantity == Decimal("10") + assert position.market_price == Decimal("27") + valuation = result.valuation + assert valuation.equity == ( + START_CASH + valuation.realized_pnl + valuation.unrealized_pnl - valuation.commission_paid + ) + + +def test_without_a_schedule_the_run_is_the_run_it_was() -> None: + plain = BacktestEngine.run( + _config(ACME), + MarketDataset.of("ACME", _quotes(ACME.asset_id, ACME_MIDS)), + _strategies(Planner({})), + context_factory, + ) + empty = BacktestEngine.run( + _config(ACME), + MarketDataset.of("ACME", _quotes(ACME.asset_id, ACME_MIDS)), + _strategies(Planner({})), + context_factory, + schedule=RunSchedule(), + ) + assert digest_run(plain) == digest_run(empty) + + +def test_an_input_after_the_data_is_never_applied() -> None: + beyond = RunSchedule.of( + timers=(TimerEvent("T-9", 99.0, timer_id="t9", schedule_type="once"),), + cash_flows=(replace(DIVIDEND, at=99.0),), + ) + result = _acme_run(beyond) + assert _instance(result).timers == [] + assert result.state.portfolio.realized_pnl.get("USD", Decimal("0")) == Decimal("0") + + +# --------------------------------------------------------------------------- # +# The replay applies the same inputs, and a payload keeps them +# --------------------------------------------------------------------------- # + +FULL = RunSchedule.of( + rates=EURUSD, + fundings=(Funding(EUR_CASH, "EUR", 1.5),), + timers=TIMERS, + cash_flows=(DIVIDEND,), + splits=(SPLIT,), +) +BOTH_MIDS = sorted( + [*_quotes(SAP.asset_id, SAP_MIDS), *_quotes(ACME.asset_id, ACME_MIDS)], + key=lambda item: item.timestamp, +) + + +def _both() -> MarketDataset: + return MarketDataset.of("BOTH", BOTH_MIDS) + + +def _both_strategies() -> Any: + return _strategies(Planner(dict(EUR_PLAN), timer_buy=ACME.asset_id)) + + +def _economics(result: BacktestResult) -> tuple[Any, ...]: + # A book in two currencies is valued in one only at stated rates. + valuation = result.valuation_in("USD", FxRates.of([EURUSD[1]])) + return (result.fills, valuation, result.equity_curve, result.trades) + + +def _replayed(pause: int | None = None) -> ReplayResult: + config = _config(SAP, ACME, two_currencies=True) + if pause is None: + result = ReplayBacktest.run( + config, _both(), _both_strategies(), context_factory, schedule=FULL + ) + assert isinstance(result, ReplayResult) + return result + paused = ReplayBacktest.run( + config, + _both(), + _both_strategies(), + context_factory, + pause_when=lambda progress: progress.records_replayed == pause, + schedule=FULL, + ) + assert isinstance(paused, PausedReplay) + payload = serialize(replay_snapshot.capture(paused)) + restored = replay_snapshot.restore( + replay_snapshot.from_primitives(deserialize(payload)), + RunObjects( + pipeline=RuntimeObjects( + sizing_model=config.pipeline.sizing_model, + simulator=config.pipeline.simulator, + strategies={STRATEGY: Planner(dict(EUR_PLAN), timer_buy=ACME.asset_id)}, + instruments=config.pipeline.instruments, + ), + fill_policy=config.fill_policy, + ), + _both(), + FULL, + ) + resumed = ReplayBacktest.resume(restored, context_factory) + assert isinstance(resumed, ReplayResult) + return resumed + + +def test_the_replay_applies_every_input_as_the_backtest_does() -> None: + backtest = BacktestEngine.run( + _config(SAP, ACME, two_currencies=True), + _both(), + _both_strategies(), + context_factory, + schedule=FULL, + ) + assert _economics(_replayed().backtest) == _economics(backtest) + assert len(backtest.fills) == 2 + + +@pytest.mark.parametrize("pause", range(len(BOTH_MIDS))) +def test_a_replay_paused_anywhere_through_a_payload_keeps_every_input(pause: int) -> None: + assert digest_run(_replayed(pause).backtest) == digest_run(_replayed().backtest) + + +@pytest.mark.parametrize("after", [None, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]) +def test_a_cursor_started_after_a_record_has_applied_exactly_what_preceded_it( + after: float | None, +) -> None: + cursor = ScheduleCursor.start(FULL, after) + applied = FULL.entries[: cursor.position] + if after is None: + assert applied == () + else: + assert all(entry.key[:2] < (after, 1) for entry in applied) + assert all(entry.key[:2] >= (after, 1) for entry in cursor.pending) + known = [rate for rate in EURUSD if after is not None and rate.as_of <= after] + assert cursor.rates == FxRates.of(known[-1:]), "the latest rate known, and no other" + + +# --------------------------------------------------------------------------- # +# Per-unit distributions and funding rates: sized by what is held when they apply +# --------------------------------------------------------------------------- # + + +def _held_run(plan: dict[tuple[float, str], Decimal], inputs: RunSchedule) -> BacktestResult: + return BacktestEngine.run( + _config(ACME), + MarketDataset.of("ACME", _quotes(ACME.asset_id, {2.0: "50", 3.0: "50", 4.0: "50"})), + _strategies(Planner(plan)), + context_factory, + schedule=inputs, + ) + + +@pytest.mark.parametrize( + ("held", "paid"), + [(Decimal("8"), Decimal("4.00")), (Decimal("-8"), Decimal("-4.00")), (Decimal("0"), None)], + ids=["long-receives", "short-pays", "flat-gets-nothing"], +) +def test_a_dividend_declared_per_share_pays_the_position_held_on_its_ex_date( + held: Decimal, paid: Decimal | None +) -> None: + dividend = ScheduledDistribution(ACME.asset_id, Decimal("0.50"), "USD", 4.0) + result = _held_run({(2.0, ACME.asset_id): held}, RunSchedule.of(distributions=(dividend,))) + booked = result.state.portfolio.realized_pnl.get("USD", Decimal("0")) + assert booked == (Decimal("0") if paid is None else paid) + flows = [e for e in result.state.portfolio.events if type(e).__name__ == "CashFlowBooked"] + assert len(flows) == (0 if paid is None else 1) + + +PERP = InstrumentRecord( + "BTCPERP", + AssetType.CRYPTO, + "XBIN", + "USD", + economics=InstrumentEconomics(Decimal("1"), SettlementModel.PERPETUAL), +) + + +def _perp_run(inputs: RunSchedule, plan: dict[tuple[float, str], Decimal]) -> BacktestResult: + return BacktestEngine.run( + _config(PERP), + MarketDataset.of("PERP", _quotes(PERP.asset_id, {2.0: "100", 3.0: "110", 4.0: "120"})), + _strategies(Planner(plan)), + context_factory, + schedule=inputs, + ) + + +def test_a_perpetuals_funding_is_charged_on_the_position_at_the_runs_mark() -> None: + # Long 2 from 2.0; funding at 3.5 is charged at the mark of 3.0 (110): 2 x 110 x 0.001. + funding = ScheduledFundingRate(PERP.asset_id, Decimal("0.001"), 3.5) + result = _perp_run( + RunSchedule.of(funding_rates=(funding,)), {(2.0, PERP.asset_id): Decimal("2")} + ) + flows = [e for e in result.state.portfolio.events if type(e).__name__ == "CashFlowBooked"] + assert len(flows) == 1 + without = _perp_run(RunSchedule(), {(2.0, PERP.asset_id): Decimal("2")}) + paid = without.valuation.realized_pnl - result.valuation.realized_pnl + assert paid == Decimal("0.22") + + +def test_a_funding_rate_before_any_position_charges_nothing() -> None: + early = ScheduledFundingRate(PERP.asset_id, Decimal("0.001"), 1.5) + # Nothing is held before 2.0, so the interval charges nothing. + assert _perp_run(RunSchedule.of(funding_rates=(early,)), {}).state.portfolio.realized_pnl.get( + "USD", Decimal("0") + ) == Decimal("0") diff --git a/tests/regression/test_documented_schemas_are_current.py b/tests/regression/test_documented_schemas_are_current.py index 4399f746..bed3016e 100644 --- a/tests/regression/test_documented_schemas_are_current.py +++ b/tests/regression/test_documented_schemas_are_current.py @@ -38,6 +38,9 @@ "EVIDENCE_SCHEMA": "alphalab.model_registry.evidence", # v4.1 (ledger FUT-001): a paused replay, the envelope around a run snapshot. "REPLAY_SNAPSHOT_SCHEMA": "alphalab.backtesting.replay_snapshot", + # v4.1.1 (ledger FUT-009): an execution plan's algorithm book, carried inside + # a paused replay's payload rather than as a durable state of its own. + "ALGORITHM_BOOK_SCHEMA": "alphalab.backtesting.algorithmic", } #: The eleven durable states' constants: every table must list each of them. diff --git a/tests/regression/test_every_adr_deferral_is_classified.py b/tests/regression/test_every_adr_deferral_is_classified.py index 524110aa..4bb1cea5 100644 --- a/tests/regression/test_every_adr_deferral_is_classified.py +++ b/tests/regression/test_every_adr_deferral_is_classified.py @@ -131,6 +131,23 @@ ("0050", "A paused replay's checkpoint is a whole capture"): "LIM-006", ("0050", "Windows is untested"): "BDY-027", ("0050", "a strategy configuration that reads back typed"): "FUT-002", + # ADR-0051, the v4.1.1 candidate: information on every driver, the capability audit. + ("0051", "A bond's accrued interest is carried by its price"): "FUT-010", + ("0051", "A feature evaluated inside a strategy recomputes its window"): "FUT-006", + ("0051", "A run's record names its last delivery, not its schedule"): "FUT-005", + ("0051", "A child's fill reaches its strategy through the portfolio, not `on_fill`"): "FUT-009", + ("0051", "Participation that completes after its window is refused"): "FUT-009", + ("0051", "A child is checked against the venue's declaration, not by risk again"): "FUT-009", + ("0051", "An algorithm is assigned per asset, not per strategy"): "FUT-009", + ("0051", "a queue-position and fill-probability model"): "FUT-011", + ("0051", "A strategy cannot instruct an exercise from its hooks"): "FUT-014", + ("0051", "Cash and deliverables move at the event's instant"): "FUT-015", + ("0051", "One deliverable, in the consideration's currency, American or European"): "FUT-016", + ("0051", "A delivery the book cannot cover stops the run"): "FUT-017", + ("0051", "a strategy-facing exercise instruction"): "FUT-014", + ("0051", "settlement-date accounting"): "FUT-015", + ("0051", "richer contract terms"): "FUT-016", + ("0051", "margin and forced liquidation"): "FUT-017", } diff --git a/tests/regression/test_heterogeneous_inputs_on_every_driver.py b/tests/regression/test_heterogeneous_inputs_on_every_driver.py new file mode 100644 index 00000000..d3c6af58 --- /dev/null +++ b/tests/regression/test_heterogeneous_inputs_on_every_driver.py @@ -0,0 +1,485 @@ +"""External information reaches a strategy the same way on every driver (v4.1.1). + +Until v4.1.1 only :meth:`BacktestEngine.run` delivered a point-in-time schedule +(ledger OFE-009). :meth:`ReplayBacktest.run` took none, so the replay of a +strategy acting on news or a macro release was a different run from its +backtest; ``alphalab.api.backtest`` and ``alphalab.api.replay`` took none, so an +application could not hand information to a run through the facade; and +:class:`~alphalab.runtime.live.LiveSession` had no call that delivered one and +routed what it produced. These tests hold each driver to the backtest's rule: +an observation is delivered at its knowledge instant, after every record of +that instant and before its slice closes; one known after the data is not +delivered; nothing is delivered twice. + +The workload is :mod:`tests.integration.replay_workload`'s -- two instruments +quoted at every instant, partial fills, a strategy with declared memory -- with +a strategy that also trades on what it is told, so a delivery moved, repeated or +lost moves an order. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import replace +from decimal import Decimal +from typing import Any + +import pytest + +from alphalab.alt_data import ExternalObservation, build_observation_set, delivery_schedule +from alphalab.alt_data.streaming import DeliverySchedule +from alphalab.backtesting import replay_snapshot +from alphalab.backtesting.engine import BacktestEngine, finalize +from alphalab.backtesting.replay import PausedReplay, ReplayBacktest +from alphalab.backtesting.schedule import RunSchedule, ScheduleCursor, ScheduleValidationError +from alphalab.backtesting.state import BacktestResult, ReplayResult +from alphalab.broker import BrokerEngine, PaperBroker +from alphalab.common.ids import id_scope +from alphalab.common.point_in_time import VisibilityRule +from alphalab.execution.costs import FREE +from alphalab.oms.snapshot import capture as capture_oms +from alphalab.persistence import deserialize, serialize +from alphalab.persistence.exceptions import StateDecodeError +from alphalab.runtime.broker_routing import RoutingConfig +from alphalab.runtime.execution_pipeline import ExecutionRouting +from alphalab.runtime.live import LiveSession +from alphalab.runtime.run import ExecutionMode, RunConfig, RunEngine +from alphalab.runtime.run_snapshot import RunObjects, capture, from_primitives, restore +from alphalab.runtime.snapshot import RuntimeObjects +from alphalab.strategy.context import StrategyContext +from alphalab.strategy.events import Intent, ObservationReceived +from tests.integration import replay_workload as workload +from tests.integration.harness import context_factory, running_strategy_state +from tests.unit.alt_data.pit_harness import SOURCE, observation + +PUBLICATION = VisibilityRule.PUBLICATION +SUBJECTS = {"FIRST": workload.FIRST, "SECOND": workload.SECOND} + +#: ``(subject, value, known_at)``. Records are quoted at 10.0 to 21.0, two an +#: instant: one is known before the first record, one at a record's own instant, +#: two at one instant between records (ordered by identity), one inside the data +#: and one after it -- which is never delivered. +READINGS = ( + ("FIRST", "1", 9.5), + ("SECOND", "-1", 12.0), + ("FIRST", "-1", 14.5), + ("SECOND", "1", 14.5), + ("FIRST", "1", 17.0), + ("SECOND", "-1", 30.0), +) + + +class InformedStrategy(workload.RememberingStrategy): + """The workload's strategy, which also takes a position on every reading it is told. + + The readings it has seen are part of its declared state, so a reading + delivered twice, or lost across a pause, changes both its memory and an order; + and each order is sized by the slices it has seen, so a reading delivered on + the wrong side of its instant's slice changes an order too. + """ + + def __init__(self) -> None: + super().__init__() + self._readings: list[str] = [] + + def on_observation( + self, context: StrategyContext, event: ObservationReceived + ) -> Iterable[Intent]: + record = event.record + assert isinstance(record, ExternalObservation) + self._readings.append(f"{event.timestamp}:{event.delivery_id}") + # Sized by the slices seen as well: a reading delivered after its + # instant's slice instead of before it is sized differently. + size = Decimal(1 + (len(self._readings) + len(self._slices)) % 3) + return ( + Intent( + strategy_id=workload.STRATEGY_ID, + instrument=SUBJECTS[event.subject], + target=size if record.value > 0 else -size, + timestamp=event.timestamp, + ), + ) + + def capture_state(self) -> dict[str, Any]: + return {**super().capture_state(), "readings": list(self._readings)} + + def restore_state(self, payload: Any, version: int) -> None: + super().restore_state(payload, version) + self._readings = [str(reading) for reading in payload["readings"]] + + +def schedule(readings: Iterable[tuple[str, str, float]] = READINGS) -> DeliverySchedule[Any]: + obs_set = build_observation_set( + "readings", + [observation(subject, "signal", value, at - 0.25, at) for subject, value, at in readings], + SOURCE, + ) + return delivery_schedule(obs_set, PUBLICATION) + + +def strategies(instance: InformedStrategy | None = None) -> Any: + return workload.strategy_state(InformedStrategy() if instance is None else instance) + + +def objects(instance: InformedStrategy | None = None) -> RunObjects: + built = workload.config() + return RunObjects( + pipeline=RuntimeObjects( + sizing_model=built.pipeline.sizing_model, + simulator=built.pipeline.simulator, + strategies={workload.STRATEGY_ID: InformedStrategy() if instance is None else instance}, + instruments=built.pipeline.instruments, + ), + fill_policy=built.fill_policy, + ) + + +def replayed(observations: Iterable[Any] = ()) -> ReplayResult: + result = ReplayBacktest.run( + workload.config(), + workload.dataset(), + strategies(), + context_factory, + observations=observations, + ) + assert isinstance(result, ReplayResult) + return result + + +def backtested(observations: Iterable[Any] = ()) -> BacktestResult: + return BacktestEngine.run( + workload.config(), workload.dataset(), strategies(), context_factory, observations + ) + + +def economics(result: BacktestResult) -> str: + """What a run did, without the mode it ran in -- which is all a replay and a backtest + of the same records may differ in.""" + + portfolio = result.state.portfolio + return serialize( + { + "orders": capture_oms(result.state.oms), + "fills": result.fills, + "trades": result.trades, + "positions": { + str(asset): (position.quantity, position.cost_basis) + for asset, position in sorted(portfolio.positions.items(), key=lambda kv: kv[0]) + }, + "cash": result.valuation.cash, + "equity_curve": result.equity_curve, + "strategy_events": result.state.strategy.events, + "observations_delivered": result.run.observations_delivered, + "last_observation": result.run.last_observation, + "processed": result.records_processed, + } + ) + + +def _readings_seen(result: BacktestResult) -> list[str]: + instance = result.state.strategy.strategies[workload.STRATEGY_ID].instance + assert isinstance(instance, InformedStrategy) + return list(instance._readings) + + +# --------------------------------------------------------------------------- # +# Replay = backtest, with information in the run +# --------------------------------------------------------------------------- # + + +def test_a_replay_delivers_observations_exactly_as_the_backtest_does() -> None: + backtest = backtested(schedule()) + replay = replayed(schedule()) + + assert economics(replay.backtest) == economics(backtest) + # Five delivered; the one known at 30.0 is after the last record (21.0). + assert replay.backtest.run.observations_delivered == 5 + assert [entry.split(":")[0] for entry in _readings_seen(replay.backtest)] == [ + "9.5", + "12.0", + "14.5", + "14.5", + "17.0", + ] + + +def test_the_information_moved_the_run() -> None: + """Without it the run is another run -- so the parity above is not vacuous.""" + + assert economics(replayed(schedule()).backtest) != economics(replayed().backtest) + + +def test_a_replay_given_no_observations_is_the_replay_it_was() -> None: + unchanged = ReplayBacktest.run( + workload.config(), workload.dataset(), strategies(), context_factory + ) + assert isinstance(unchanged, ReplayResult) + assert workload.outcome(replayed()) == workload.outcome(unchanged) + assert replayed().backtest.run.observations_delivered == 0 + + +def test_an_order_decided_on_information_never_fills_before_it_was_known() -> None: + result = replayed(schedule()) + known = { + str(order.order_id.value): order.created_at + for order in result.backtest.state.oms.orders.orders() + } + for fill in result.backtest.fills: + assert fill.filled_at >= known[fill.order_id] + decided = [at for at in known.values() if at in {9.5, 12.0, 14.5, 17.0}] + assert decided, "orders were placed at information instants" + + +# --------------------------------------------------------------------------- # +# Pause and resume, in process and through a payload +# --------------------------------------------------------------------------- # + + +def _paused(boundary: int, readings: Iterable[tuple[str, str, float]] = READINGS) -> PausedReplay: + paused = ReplayBacktest.run( + workload.config(), + workload.dataset(), + strategies(), + context_factory, + pause_when=workload.pause_at(boundary), + observations=schedule(readings), + ) + assert isinstance(paused, PausedReplay) + return paused + + +def _through_a_payload(paused: PausedReplay, inputs: RunSchedule) -> PausedReplay: + payload = serialize(replay_snapshot.capture(paused)) + return replay_snapshot.restore( + replay_snapshot.from_primitives(deserialize(payload)), + objects(), + workload.dataset(), + inputs, + ) + + +@pytest.mark.parametrize("boundary", range(workload.TOTAL)) +def test_a_replay_paused_anywhere_resumes_into_the_uninterrupted_replay(boundary: int) -> None: + reference = workload.outcome(replayed(schedule())) + paused = _paused(boundary) + taken = paused.records_replayed + after = None if taken == 0 else workload.dataset().records[taken - 1].timestamp + pending = ScheduleCursor.start(paused.schedule, after).pending + assert len(pending) + paused.run.observations_delivered == len(schedule()) + + resumed = ReplayBacktest.resume(paused, context_factory) + assert isinstance(resumed, ReplayResult) + assert workload.outcome(resumed) == reference + + +@pytest.mark.parametrize("boundary", range(workload.TOTAL)) +def test_a_replay_with_information_outstanding_resumes_from_its_payload(boundary: int) -> None: + """FUT-003: the payload records the schedule's digest, and loses nothing.""" + + reference = workload.outcome(replayed(schedule())) + paused = _paused(boundary) + restored = _through_a_payload(paused, RunSchedule.of(observations=schedule())) + + resumed = ReplayBacktest.resume(restored, context_factory) + assert isinstance(resumed, ReplayResult) + assert workload.outcome(resumed) == reference + + +def test_a_pause_at_every_boundary_in_one_chain_changes_nothing() -> None: + reference = workload.outcome(replayed(schedule())) + inputs = RunSchedule.of(observations=schedule()) + state: ReplayResult | PausedReplay = _paused(0) + while isinstance(state, PausedReplay): + # Each pause written down and read back with fresh objects. + restored = _through_a_payload(state, inputs) + state = ReplayBacktest.resume( + restored, context_factory, pause_when=workload.pause_at(state.records_replayed + 1) + ) + assert workload.outcome(state) == reference + + +def test_a_payload_resumed_with_another_schedule_or_none_is_refused() -> None: + paused = _paused(6) + payload = deserialize(serialize(replay_snapshot.capture(paused))) + snapshot = replay_snapshot.from_primitives(payload) + assert snapshot.schedule_digest == RunSchedule.of(observations=schedule()).digest + + with pytest.raises(StateDecodeError, match="none was supplied"): + replay_snapshot.restore(snapshot, objects(), workload.dataset()) + other = RunSchedule.of(observations=schedule(READINGS[:-1])) + with pytest.raises(StateDecodeError, match="was supplied; resuming would apply other"): + replay_snapshot.restore(snapshot, objects(), workload.dataset(), other) + + plain = ReplayBacktest.run( + workload.config(), + workload.dataset(), + strategies(), + context_factory, + pause_when=workload.pause_at(6), + ) + assert isinstance(plain, PausedReplay) + bare = replay_snapshot.from_primitives(deserialize(serialize(replay_snapshot.capture(plain)))) + assert bare.schedule_digest is None + with pytest.raises(StateDecodeError, match="paused with no schedule"): + replay_snapshot.restore(bare, objects(), workload.dataset(), other) + + +def test_a_version_1_payload_reads_as_a_replay_given_no_schedule() -> None: + plain = ReplayBacktest.run( + workload.config(), + workload.dataset(), + strategies(), + context_factory, + pause_when=workload.pause_at(6), + ) + assert isinstance(plain, PausedReplay) + payload = deserialize(serialize(replay_snapshot.capture(plain))) + assert payload["schema_version"] == 2 + payload["schema_version"] = 1 + del payload["schedule_digest"] + + assert replay_snapshot.from_primitives(payload).schedule_digest is None + + +def test_deliveries_known_only_after_the_data_round_trip_and_are_never_made() -> None: + inside = tuple(reading for reading in READINGS if reading[2] < 15.0) + beyond = (*inside, ("SECOND", "-1", 30.0), ("FIRST", "1", 99.0)) + reference = replayed(schedule(beyond)) + restored = _through_a_payload( + _paused(14, beyond), RunSchedule.of(observations=schedule(beyond)) + ) + resumed = ReplayBacktest.resume(restored, context_factory) + assert isinstance(resumed, ReplayResult) + assert workload.outcome(resumed) == workload.outcome(reference) + assert resumed.backtest.run.observations_delivered == len(inside) + + +# --------------------------------------------------------------------------- # +# A backtest stopped and continued by hand, with the public cursor +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("stop_after", range(1, workload.TOTAL)) +def test_a_backtest_continued_from_a_snapshot_delivers_the_rest_exactly_once( + stop_after: int, +) -> None: + """The loop :meth:`BacktestEngine.run` takes, written out around a payload. + + ``ScheduleCursor.start`` with the timestamp of the record the run stopped + after hands the continuation exactly the entries it has not applied. + """ + + config = workload.config() + records = workload.dataset().records + inputs = RunSchedule.of(observations=schedule()) + reference = backtested(schedule()) + assert config.seed is not None + + # Built outside the run's scope, as BacktestEngine.run's caller builds it: + # starting a strategy records lifecycle events, which draw identifiers. + initial = strategies() + with id_scope(config.seed): + state = replace(BacktestEngine.initialize(config, initial), source_id=workload.DATASET_ID) + cursor = ScheduleCursor.start(inputs) + for record in records[:stop_after]: + cursor, state = cursor.before(state, record.timestamp, context_factory) + state = RunEngine.close_slice( + state, context_factory, cursor.rates, before=record.timestamp + ) + state, _ = RunEngine.advance(state, record, context_factory, rates=cursor.rates) + payload = serialize(capture(state)) + + restored = restore(from_primitives(deserialize(payload)), objects()) + cursor = ScheduleCursor.start(inputs, records[stop_after - 1].timestamp) + with BacktestEngine.resume(restored): + for record in records[stop_after:]: + cursor, restored = cursor.before(restored, record.timestamp, context_factory) + restored = RunEngine.close_slice( + restored, context_factory, cursor.rates, before=record.timestamp + ) + restored, _ = RunEngine.advance(restored, record, context_factory, rates=cursor.rates) + cursor, restored = cursor.finish(restored, context_factory) + finished = finalize(RunEngine.close_slice(restored, context_factory, cursor.rates)) + + assert economics(finished) == economics(reference) + assert _readings_seen(finished) == _readings_seen(reference) + + +def test_the_merge_is_the_one_the_backtest_has_always_used() -> None: + """Any order in, one order out; and the rule for observations is unchanged.""" + + first = backtested(schedule()) + second = backtested(list(reversed(schedule().deliveries))) + assert economics(first) == economics(second) + inputs = RunSchedule.of(observations=schedule()) + assert [entry.at for entry in inputs.entries] == [9.5, 12.0, 14.5, 14.5, 17.0, 30.0] + # After the records at 14.0: everything ordered before them was applied. + assert [entry.at for entry in ScheduleCursor.start(inputs, 14.0).pending] == [ + 14.5, + 14.5, + 17.0, + 30.0, + ] + # After the records at 12.0: the observation known at 12.0 waits for the + # instant's records and is delivered when the next instant arrives. + assert ScheduleCursor.start(inputs, 12.0).pending[0].at == 12.0 + + +def test_one_observation_given_twice_is_refused() -> None: + (first, *_) = schedule().deliveries + with pytest.raises(ScheduleValidationError, match="observation"): + RunSchedule.of(observations=(first, first)) + with pytest.raises(ScheduleValidationError, match="given twice"): + RunSchedule.of(observations=(first,)).with_observations((first,)) + + +# --------------------------------------------------------------------------- # +# Live: delivered when it arrived, and routed +# --------------------------------------------------------------------------- # + + +def test_a_live_session_delivers_an_observation_when_it_arrived_and_routes_it() -> None: + config = workload.config() + live = RunConfig( + pipeline=replace(config.pipeline, routing=ExecutionRouting.EXTERNAL), + mode=ExecutionMode.LIVE, + seed=7, + start_timestamp=1.0, + compile_analytics=False, + ) + venue = PaperBroker(FREE) + strategy = InformedStrategy() + state = LiveSession.initialize( + live, + running_strategy_state(workload.STRATEGY_ID, strategy, frozenset({"*", "slices"})), + BrokerEngine.initialize("VENUE-X", Decimal("1000000"), "USD"), + RoutingConfig(venue="VENUE-X", currency="USD"), + ) + state, _ = LiveSession.connect(state, venue, 1.5) + for record in workload.dataset().records[:2]: + state, _ = LiveSession.advance(state, record, context_factory, venue) + + # Known at 9.5, but the host received it at 10.5: it is delivered at 10.5. + delivery = schedule().deliveries[0] + state, routed, _ = LiveSession.deliver_observation( + state, delivery, venue, context_factory, now=10.5 + ) + + assert strategy._readings == [f"10.5:{delivery.delivery_id}"] + assert state.run.last_observation == delivery.order_key + ((attempt,),) = (routed,) + assert attempt.routed + + # Delivered once: the same delivery again does nothing and sends nothing. + again, routed_again, _ = LiveSession.deliver_observation( + state, delivery, venue, context_factory, now=10.6 + ) + assert routed_again == () and again.run == state.run + + # route=False leaves the order working for the caller to route. + second = schedule().deliveries[1] + held, nothing, _ = LiveSession.deliver_observation( + state, second, venue, context_factory, now=12.5, route=False + ) + assert nothing == () + assert held.unrouted_orders diff --git a/tests/regression/test_no_silent_financial_defaults.py b/tests/regression/test_no_silent_financial_defaults.py index 2529c48b..4eb1e07d 100644 --- a/tests/regression/test_no_silent_financial_defaults.py +++ b/tests/regression/test_no_silent_financial_defaults.py @@ -95,6 +95,9 @@ PERMITTED: dict[tuple[str, str], str] = { # The empty table, not a rate. Every consumer refuses when it needs one. ("*", "rates"): "NO_RATES is the absence of rates, which is what refuses", + # A schedule's funding rates, empty when none is stated: nothing is charged + # without a stated rate, and a stated one without a mark is refused. + ("RunSchedule.of", "funding_rates"): "no funding rate stated, so none is charged", # ADR-0027: classification provenance defaults to the operator, which is a # named constant meaning "a person did this", not an invented source. ("classify_instrument", "source"): "OPERATOR is a named principal, not a source", diff --git a/tests/regression/test_persisted_enum_names.py b/tests/regression/test_persisted_enum_names.py index c73cd099..8b715875 100644 --- a/tests/regression/test_persisted_enum_names.py +++ b/tests/regression/test_persisted_enum_names.py @@ -77,7 +77,11 @@ "VARIATION_MARGIN", "FUNDING", ), - "alphalab.runtime.execution_pipeline.ExecutionRouting": ("SIMULATED", "EXTERNAL"), + "alphalab.runtime.execution_pipeline.ExecutionRouting": ( + "SIMULATED", + "EXTERNAL", + "ALGORITHMIC", + ), "alphalab.runtime.execution_pipeline.UnpricedReason": ( "NO_PRICE_OBSERVED", "NOT_REGISTERED", @@ -113,7 +117,18 @@ "at_the_opening", "at_the_close", ), - "alphalab.core.enums.AssetType": ("equity", "future", "option", "forex", "crypto", "cash"), + # v4.1.1 added "bond" (ledger FUT-010) and "index" (FUT-013), last, so no + # written value moved. + "alphalab.core.enums.AssetType": ( + "equity", + "future", + "option", + "forex", + "crypto", + "cash", + "bond", + "index", + ), "alphalab.core.enums.OrderStatus": ( "new", "pending", diff --git a/tests/regression/test_replay_pause_and_resume.py b/tests/regression/test_replay_pause_and_resume.py index 681505ac..306a2742 100644 --- a/tests/regression/test_replay_pause_and_resume.py +++ b/tests/regression/test_replay_pause_and_resume.py @@ -373,7 +373,8 @@ def test_capture_is_pure_and_repeatable() -> None: first, second = capture(paused), capture(paused) assert current_id_position() == before, "capture draws no identifier" assert first == second and serialize(first) == serialize(second) - assert first.schema_version == REPLAY_SNAPSHOT_SCHEMA == 1 + # v4.1.1 moved it to 2: the schedule's digest (ledger FUT-003). + assert first.schema_version == REPLAY_SNAPSHOT_SCHEMA == 2 def test_restoring_one_payload_twice_gives_two_equal_continuations() -> None: @@ -625,7 +626,7 @@ def test_a_truncated_payload_is_refused_as_json() -> None: [ (None, "missing 'schema_version'"), (0, "declares schema version 0"), - (2, "declares schema version 2"), + (3, "declares schema version 3"), ("1", "not an integer"), (True, "not an integer"), ], @@ -913,7 +914,13 @@ def test_the_frozen_v4_1_0_payload_reads_and_resumes() -> None: def test_the_frozen_payload_is_what_this_build_writes() -> None: - assert json.loads(FIXTURE.read_text(encoding="utf-8")) == _payload(_paused(11)) + """Byte for byte, but for the one step schema 2 declares (v4.1.1, ledgers FUT-003 and + FUT-009): the version, the digest of a schedule and the algorithm book of an + execution plan, neither of which a v4.1.0 replay could have had.""" + + frozen = json.loads(FIXTURE.read_text(encoding="utf-8")) + upgraded = {**frozen, "schema_version": 2, "schedule_digest": None, "algorithms": None} + assert upgraded == _payload(_paused(11)) def test_the_snapshot_covers_every_cursor_field() -> None: @@ -928,6 +935,15 @@ def test_the_snapshot_covers_every_cursor_field() -> None: "run", "cursor", "cursor_position", + # v4.1.1: carried as its digest (schema 2), the schedule itself supplied + # back on restore and held to it, as the dataset is (ledger FUT-003; + # test_heterogeneous_inputs_on_every_driver.py + # ::test_a_payload_resumed_with_another_schedule_or_none_is_refused). + "schedule", + # v4.1.1: carried whole (schema 2), the plan supplied back and held to it + # (ledger FUT-009; test_algorithms_on_the_historical_path.py + # ::test_a_replay_paused_with_children_working_resumes_from_its_payload). + "algorithms", } diff --git a/tests/regression/test_schema_upgrades.py b/tests/regression/test_schema_upgrades.py index 496f7c7b..1fbd6f85 100644 --- a/tests/regression/test_schema_upgrades.py +++ b/tests/regression/test_schema_upgrades.py @@ -181,7 +181,8 @@ def test_a_v3_9_instrument_registry_is_upgraded_with_no_alias_invented() -> None recaptured = deserialize(serialize(snapshot)) # v3.12 moved it to 3: no instrument was classified along another dimension. - assert recaptured["schema_version"] == 3 + # v4.1.1 moved it to 4: no instrument declared a last trading instant. + assert recaptured["schema_version"] == 4 expected = [ { **entry, @@ -190,6 +191,8 @@ def test_a_v3_9_instrument_registry_is_upgraded_with_no_alias_invented() -> None "later_dated_aliases": [], "economics": None, "dimensions": {}, + "last_trade_at": None, + "terms": None, } for entry in payload["instruments"] ] diff --git a/tests/regression/test_schema_upgrades_v3_11.py b/tests/regression/test_schema_upgrades_v3_11.py index ceb54dbc..cbebe807 100644 --- a/tests/regression/test_schema_upgrades_v3_11.py +++ b/tests/regression/test_schema_upgrades_v3_11.py @@ -92,12 +92,13 @@ def test_a_v3_11_registry_is_upgraded_with_its_sector_as_a_dimension() -> None: snapshot = from_primitives(payload) registry = restore(snapshot) - assert snapshot.schema_version == 3 + # v4.1.1 moved the instrument schema to 4 (a last trading instant, FUT-012). + assert snapshot.schema_version == 4 acme = next(record for record in registry.instruments.values() if record.symbol == "ACME") assert registry.label_of(acme.asset_id, SECTOR) == "Technology" assert acme.asset_id in registry.bucket_members(SECTOR, "Technology") assert acme.dated_aliases, "the v3.11 dated alias is carried" - assert deserialize(serialize(snapshot))["schema_version"] == 3 + assert deserialize(serialize(snapshot))["schema_version"] == 4 def _run_objects(strategy_id: str, plan: dict[float, Decimal]) -> Any: diff --git a/tests/regression/test_schema_upgrades_v3_12.py b/tests/regression/test_schema_upgrades_v3_12.py index b9030d6d..16b8c508 100644 --- a/tests/regression/test_schema_upgrades_v3_12.py +++ b/tests/regression/test_schema_upgrades_v3_12.py @@ -82,7 +82,6 @@ def _upgraded_run(payload: dict[str, Any]) -> dict[str, Any]: ("oms.json", "alphalab.oms.snapshot"), ("allocation.json", "alphalab.allocation.snapshot"), ("allocation_eur.json", "alphalab.allocation.snapshot"), - ("instrument.json", "alphalab.instrument.snapshot"), ("lifecycle.json", "alphalab.lifecycle.snapshot"), ("fx_feed.json", "alphalab.portfolio.fx_feed"), ("broker.json", "alphalab.broker.snapshot"), @@ -217,3 +216,26 @@ def test_the_old_name_is_not_a_second_dialect_of_version_7() -> None: StateDecodeError, match=r"is not a StrategyStatus: 'LifecycleState\.RUNNING'" ): from_primitives(payload) + + +def test_the_v3_12_instrument_registry_reads_with_no_contract_expiring() -> None: + """The one v3.12 subsystem payload v4.1.1 moves (schema 3 to 4, ledger FUT-012). + + Read, it is the registry it was; written again, it is the same payload with + two fields more on every record -- no last trading instant and no contract + terms, which nothing before v4.1.1 could declare -- and the new version. + """ + + from alphalab.instrument.snapshot import from_primitives, restore + + payload = _load("instrument.json") + snapshot = from_primitives(payload) + expected = { + **payload, + "schema_version": 4, + "instruments": [ + {**entry, "last_trade_at": None, "terms": None} for entry in payload["instruments"] + ], + } + assert deserialize(serialize(snapshot)) == expected + assert all(record.last_trade_at is None for record in restore(snapshot).instruments.values()) diff --git a/tests/regression/test_schema_upgrades_v3_13.py b/tests/regression/test_schema_upgrades_v3_13.py index c16efc4f..927decea 100644 --- a/tests/regression/test_schema_upgrades_v3_13.py +++ b/tests/regression/test_schema_upgrades_v3_13.py @@ -65,7 +65,6 @@ def _upgraded_run(payload: dict[str, Any]) -> dict[str, Any]: ("oms.json", "alphalab.oms.snapshot"), ("allocation.json", "alphalab.allocation.snapshot"), ("allocation_eur.json", "alphalab.allocation.snapshot"), - ("instrument.json", "alphalab.instrument.snapshot"), ("lifecycle.json", "alphalab.lifecycle.snapshot"), ("fx_feed.json", "alphalab.portfolio.fx_feed"), ("broker.json", "alphalab.broker.snapshot"), @@ -248,3 +247,26 @@ def test_version_8_is_not_a_second_dialect_of_version_7() -> None: payload["pipeline"]["schema_version"] = 8 with pytest.raises(StateDecodeError, match="sizing_model_description"): from_primitives(payload) + + +def test_the_v3_13_instrument_registry_reads_with_no_contract_expiring() -> None: + """The one v3.13 subsystem payload v4.1.1 moves (schema 3 to 4, ledger FUT-012). + + Read, it is the registry it was; written again, it is the same payload with + two fields more on every record -- no last trading instant and no contract + terms, which nothing before v4.1.1 could declare -- and the new version. + """ + + from alphalab.instrument.snapshot import from_primitives, restore + + payload = _load("instrument.json") + snapshot = from_primitives(payload) + expected = { + **payload, + "schema_version": 4, + "instruments": [ + {**entry, "last_trade_at": None, "terms": None} for entry in payload["instruments"] + ], + } + assert deserialize(serialize(snapshot)) == expected + assert all(record.last_trade_at is None for record in restore(snapshot).instruments.values()) diff --git a/tests/regression/test_sector_classification_reaches_attribution.py b/tests/regression/test_sector_classification_reaches_attribution.py index 9b1c3a5a..b4ff05a7 100644 --- a/tests/regression/test_sector_classification_reaches_attribution.py +++ b/tests/regression/test_sector_classification_reaches_attribution.py @@ -680,8 +680,14 @@ def test_the_runtime_reads_the_registry_through_keyed_reads_and_nothing_else() - # (v3.11, FEA-001) one in `_instrument_grid`, once per distinct asset a # batch of intents names, for the lot grid and minimum notional it declares; # and (v3.12, EXE-010) one in `_listing_exchange`, once per simulated resting - # day order that states no close, for the venue whose calendar it reads. - assert source.count("record_for(") == 6 + # day order that states no close, for the venue whose calendar it reads; + # and (v4.1.1, FUT-012) one in `settle_contract`, once per final settlement, + # for the contract's expiry and economics; and (v4.1.1, FUT-013) one in + # `_declared`, once per contract or deliverable an exercise, assignment, + # expiry or delivery names. The per-record expiry check reads the registry's + # `expiring` index instead -- and nothing when it is empty. + assert source.count("record_for(") == 8 + assert source.count(".expiring") == 2 assert source.count(".label_of(") == 2 assert source.count(".bucket_members(") == 1 assert source.count(".members") == 1 diff --git a/tests/regression/test_strategy_context_reads_bars.py b/tests/regression/test_strategy_context_reads_bars.py new file mode 100644 index 00000000..980f0202 --- /dev/null +++ b/tests/regression/test_strategy_context_reads_bars.py @@ -0,0 +1,99 @@ +"""A strategy's context returns the bars the market holds (v4.1.1). + +:class:`~alphalab.market.engine.MarketEngine` keeps the latest bar of an asset +per *interval*, keyed ``"_"``. +:meth:`MarketView.bar ` -- +``context.market.bar(asset_id)``, on the documented +:class:`~alphalab.strategy.context.MarketViewProtocol` -- looked the bare +``asset_id`` up in that index from v2.10 until v4.1.1, which no key equals, so +it answered ``None`` for every asset in every run and no test read one. Found +writing the v4.1.1 acceptance scenario, whose hedge sizes a futures position +from the future's last settlement and so never hedged. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from decimal import Decimal +from typing import Any + +from alphalab.backtesting import BacktestEngine +from alphalab.backtesting.dataset import MarketDataset +from alphalab.market.bar import Bar, TimeFrame +from alphalab.market.engine import MarketEngine +from alphalab.market.state import MarketState +from alphalab.runtime.context_views import MarketView +from alphalab.strategy.context import StrategyContext +from alphalab.strategy.events import Intent +from alphalab.strategy.protocol import BaseStrategy +from tests.integration.harness import backtest_config, context_factory, running_strategy_state + +ASSET = "7c9e6679-7425-40de-944b-e07fc1f90ae7" +OTHER = "0b7c3a52-3e43-4d0e-8f6c-1a9e5d2c7b10" + + +def _bar(asset: str, at: float, close: str, timeframe: TimeFrame = TimeFrame.D1) -> Bar: + price = Decimal(close) + return Bar(asset, at, price, price, price, price, Decimal("1000"), None, None, timeframe) + + +def _market(*bars: Bar) -> MarketState: + state = MarketEngine.reset() + for bar in bars: + state = MarketEngine.publish_bar(state, bar) + return state + + +def test_the_view_returns_the_bar_the_market_engine_published() -> None: + bar = _bar(ASSET, 10.0, "101") + view = MarketView(_market(bar)) + + assert view.bar(ASSET) == bar + assert view.bar(OTHER) is None + + +def test_an_asset_never_barred_has_no_bar_even_when_its_id_prefixes_anothers() -> None: + view = MarketView(_market(_bar(ASSET, 10.0, "101"))) + + assert view.bar(ASSET[:8]) is None + assert view.bar("") is None + + +def test_of_several_intervals_the_latest_bar_is_returned() -> None: + hourly = _bar(ASSET, 20.0, "102", TimeFrame.H1) + daily = _bar(ASSET, 10.0, "101", TimeFrame.D1) + + assert MarketView(_market(daily, hourly)).bar(ASSET) == hourly + assert MarketView(_market(hourly, daily)).bar(ASSET) == hourly, "not publication order" + + +def test_two_bars_closing_at_one_instant_give_the_longer_interval() -> None: + hourly = _bar(ASSET, 30.0, "103", TimeFrame.H1) + daily = _bar(ASSET, 30.0, "104", TimeFrame.D1) + monthly = _bar(ASSET, 30.0, "105", TimeFrame.MN1) + + assert MarketView(_market(hourly, daily)).bar(ASSET) == daily + assert MarketView(_market(monthly, daily, hourly)).bar(ASSET) == monthly + + +class _Reader(BaseStrategy): + def __init__(self) -> None: + self.read: list[Any] = [] + + def on_bar(self, context: StrategyContext, event: Any) -> Iterable[Intent]: + self.read.append(context.market.bar(event.bar.asset_id)) + return () + + +def test_a_strategy_reads_the_bar_it_is_dispatched_on_through_its_context() -> None: + bars = [_bar(ASSET, 10.0 + day, str(100 + day)) for day in range(3)] + strategy = _Reader() + + BacktestEngine.run( + backtest_config("A"), + MarketDataset.of("BARS", bars), + running_strategy_state("A", strategy, frozenset({"bars"})), + context_factory, + ) + + assert strategy.read == bars diff --git a/tests/regression/test_v37_invariants.py b/tests/regression/test_v37_invariants.py index 91a4a633..8736b383 100644 --- a/tests/regression/test_v37_invariants.py +++ b/tests/regression/test_v37_invariants.py @@ -773,8 +773,11 @@ def test_v37_added_no_snapshot_owner_and_no_schema_constant() -> None: # other: the evidence store's envelope (ledger OFE-016, BDY-008) and the # checkpoint chain's envelope around a run snapshot (ledger PRF-004). v4.1 # added one more, as deliberately: a paused replay's envelope around a run - # snapshot (ledger FUT-001). + # snapshot (ledger FUT-001). v4.1.1 added an execution plan's algorithm book, + # carried in a paused replay and stored beside a continued backtest's run + # (ledger FUT-009). assert constants == [ + "ALGORITHM_BOOK_SCHEMA", "ALLOCATION_SNAPSHOT_SCHEMA", "BROKER_SNAPSHOT_SCHEMA", "CHECKPOINT_SCHEMA", diff --git a/tests/regression/test_v38_invariants.py b/tests/regression/test_v38_invariants.py index e6382d6f..5c38f1df 100644 --- a/tests/regression/test_v38_invariants.py +++ b/tests/regression/test_v38_invariants.py @@ -1107,8 +1107,11 @@ def test_v38_added_no_snapshot_owner_and_no_schema_constant() -> None: # other: the evidence store's envelope (ledger OFE-016, BDY-008) and the # checkpoint chain's envelope around a run snapshot (ledger PRF-004). v4.1 # added one more, as deliberately: a paused replay's envelope around a run - # snapshot (ledger FUT-001). + # snapshot (ledger FUT-001). v4.1.1 added an execution plan's algorithm book, + # carried in a paused replay and stored beside a continued backtest's run + # (ledger FUT-009). assert constants == [ + "ALGORITHM_BOOK_SCHEMA", "ALLOCATION_SNAPSHOT_SCHEMA", "BROKER_SNAPSHOT_SCHEMA", "CHECKPOINT_SCHEMA", diff --git a/tests/regression/test_v39_invariants.py b/tests/regression/test_v39_invariants.py index 41cc9e71..5f81abf4 100644 --- a/tests/regression/test_v39_invariants.py +++ b/tests/regression/test_v39_invariants.py @@ -475,26 +475,83 @@ def _random_requirements(rng: random.Random) -> ExecutionRequirements: ) +def _required(rng: random.Random, needed: bool) -> Support: + """``SUPPORTED`` where the flow needs it; anything where it does not.""" + + return Support.SUPPORTED if needed else _random_support(rng) + + +def _satisfying_declaration( + rng: random.Random, requirements: ExecutionRequirements +) -> CapabilityDeclaration: + """A declaration offering everything ``requirements`` needs, and arbitrary otherwise. + + A random declaration is almost never compatible -- at v4.1.0 two draws in + 600 were, so the status was reached by the luck of the seed, and one more + ``AssetType`` member moved the stream past it. This reaches it by + construction, so every draw also checks the converse. + """ + + others = [asset for asset in AssetType if asset is not requirements.asset_class] + features = [Capability.CANCEL_REPLACE, Capability.STREAMING] + supported = frozenset(requirements.features) | frozenset( + f for f in features if rng.random() < 0.5 + ) + market = MarketCapability( + requirements.asset_class, + rng.choice([ANY_LISTING_VENUE, requirements.listing_venue]), + frozenset(requirements.order_types) | frozenset(rng.sample(list(OrderType), 1)), + frozenset(requirements.time_in_force) | frozenset(rng.sample(list(TimeInForce), 1)), + _required(rng, requirements.short_selling), + _required(rng, requirements.fractional_quantities), + _required(rng, requirements.extended_hours), + _required(rng, requirements.bracket_orders), + ) + account = AccountCapability( + requirements.account_id, + frozenset({requirements.asset_class, *rng.sample(others, rng.randint(0, 2))}), + _required(rng, requirements.margin), + _required(rng, requirements.short_selling), + ) + return CapabilityDeclaration( + "adapter", + supported, + frozenset(f for f in features if f not in supported and rng.random() < 0.5), + (market,), + frozenset(rng.sample(others, rng.randint(0, 2))), + (account,), + ) + + +def _check_the_report( + requirements: ExecutionRequirements, declaration: CapabilityDeclaration +) -> Compatibility: + report = check_compatibility(requirements, declaration) + supports = [check.support for check in report.checks] + if report.status is Compatibility.COMPATIBLE: + assert all(s is Support.SUPPORTED for s in supports) + elif report.status is Compatibility.INCOMPATIBLE: + assert Support.UNSUPPORTED in supports + else: + assert Support.UNDECLARED in supports and Support.UNSUPPORTED not in supports + # Every order type and time-in-force required is checked, once. + assert {c.requirement for c in report.checks} >= { + f"order_type={t}" for t in requirements.order_types + } + assert check_compatibility(requirements, declaration) == report + return report.status + + def test_compatible_means_every_check_supported_and_nothing_else_does() -> None: rng = random.Random(3939) seen: set[Compatibility] = set() for _ in range(600): - declaration = _random_declaration(rng) requirements = _random_requirements(rng) - report = check_compatibility(requirements, declaration) - seen.add(report.status) - supports = [check.support for check in report.checks] - if report.status is Compatibility.COMPATIBLE: - assert all(s is Support.SUPPORTED for s in supports) - elif report.status is Compatibility.INCOMPATIBLE: - assert Support.UNSUPPORTED in supports - else: - assert Support.UNDECLARED in supports and Support.UNSUPPORTED not in supports - # Every order type and time-in-force required is checked, once. - assert {c.requirement for c in report.checks} >= { - f"order_type={t}" for t in requirements.order_types - } - assert check_compatibility(requirements, declaration) == report + seen.add(_check_the_report(requirements, _random_declaration(rng))) + # A declaration offering everything the flow needs is compatible with it. + offered = _check_the_report(requirements, _satisfying_declaration(rng, requirements)) + assert offered is Compatibility.COMPATIBLE, requirements + seen.add(offered) assert seen == set(Compatibility) diff --git a/tests/unit/lifecycle/test_reproducibility.py b/tests/unit/lifecycle/test_reproducibility.py index 8c5a3673..5bbb9c48 100644 --- a/tests/unit/lifecycle/test_reproducibility.py +++ b/tests/unit/lifecycle/test_reproducibility.py @@ -17,9 +17,12 @@ import pytest +from alphalab.api import backtest +from alphalab.backtesting import AlgorithmAssignment, AlgorithmicResult, ExecutionPlan from alphalab.data.dataset import Dataset from alphalab.data.exceptions import DataValidationError from alphalab.data.time import BarStamp +from alphalab.execution.algorithms import TWAP, Urgency from alphalab.factor_library.definition import FeatureDefinition, FeatureField, FeatureKind from alphalab.lifecycle import ( NO_DEPENDENCIES, @@ -46,16 +49,22 @@ ) from alphalab.research.study import ResearchStudy, StudyResult, build_result from alphalab.runtime.run import ExecutionMode +from tests.integration.harness import context_factory, running_strategy_state from tests.unit.lifecycle.evidence_harness import ( + ASSET_ID, BUILD, CODE, ENGINE, + NORMALIZATION, + PLAN, SEED, STRATEGY_ID, SYMBOL, + BarStrategy, fingerprint, ingest, run_backtest, + run_config, ) # --------------------------------------------------------------------------- # @@ -566,3 +575,67 @@ def test_no_dependencies_declared_exactly_is_complete(dataset: Dataset) -> None: ) assert manifest_gaps(manifest) == () + + +# --------------------------------------------------------------------------- # +# A run whose orders an execution plan worked in children (v4.1.1, FUT-009) +# --------------------------------------------------------------------------- # + +WINDOW_PLAN = ExecutionPlan( + AlgorithmAssignment(TWAP(2, Urgency.neutral()), 3 * 86_400.0, Decimal("1")) +) + + +def _algorithmic(dataset: Dataset, plan: ExecutionPlan = WINDOW_PLAN) -> AlgorithmicResult: + return backtest( + run_config(STRATEGY_ID, SEED), + dataset, + running_strategy_state(STRATEGY_ID, BarStrategy(STRATEGY_ID, ASSET_ID, PLAN)), + context_factory, + NORMALIZATION, + execution=plan, + ) + + +def test_a_manifest_names_the_plan_that_worked_the_runs_orders(dataset: Dataset) -> None: + result = _algorithmic(dataset) + assert result.algorithms is not None and result.algorithms.fills + manifest = manifest_for_run( + result, dataset, fingerprint(), ENGINE, build=BUILD, execution=WINDOW_PLAN + ) + assert manifest.execution_plan_id == WINDOW_PLAN.plan_id + assert verify_manifest(manifest) + assert not verify_manifest(replace(manifest, execution_plan_id="0" * 64)) + assert any( + WINDOW_PLAN.plan_id in requirement.detail for requirement in external_requirements(manifest) + ) + # The plan's line enters the identity only when there is a plan. + plain = manifest_for_run(run_backtest(dataset), dataset, fingerprint(), ENGINE, build=BUILD) + assert plain.execution_plan_id is None + assert "inputs.execution_plan" not in canonical_manifest_key( + plain.kind, + plain.result_id, + plain.dataset_version, + plain.dataset_content_hash, + plain.configuration_id, + plain.seed, + plain.seed_role, + plain.engine, + plain.fingerprint.fingerprint if plain.fingerprint else None, + plain.build, + ) + + +def test_a_manifest_refuses_a_plan_the_run_was_not_worked_by(dataset: Dataset) -> None: + result = _algorithmic(dataset) + with pytest.raises(LifecycleInputError, match="no execution plan was given"): + manifest_for_run(result, dataset, fingerprint(), ENGINE, build=BUILD) + other = ExecutionPlan( + AlgorithmAssignment(TWAP(3, Urgency.neutral()), 3 * 86_400.0, Decimal("1")) + ) + with pytest.raises(LifecycleInputError, match="worked by plan"): + manifest_for_run(result, dataset, fingerprint(), ENGINE, build=BUILD, execution=other) + with pytest.raises(LifecycleInputError, match="filled its orders whole"): + manifest_for_run( + run_backtest(dataset), dataset, fingerprint(), ENGINE, build=BUILD, execution=other + ) diff --git a/tests/unit/test_api_combined_datasets.py b/tests/unit/test_api_combined_datasets.py new file mode 100644 index 00000000..4bd17265 --- /dev/null +++ b/tests/unit/test_api_combined_datasets.py @@ -0,0 +1,182 @@ +"""Several canonical datasets read by one run, under one derived identity (v4.1.1). + +A dataset is one series kind from one source, so a strategy trading an equity +and a future, or reading two providers, reads several. ``combine_datasets`` +keeps each one's provenance and derives the run's identity from them -- from +their versions and content hashes, in the order given, because that order +breaks ties between records sharing an instant. Nothing is aligned, filled or +carried forward, and two answers to one question are refused. +""" + +from __future__ import annotations + +import hashlib +from collections.abc import Mapping, Sequence +from dataclasses import replace +from decimal import Decimal + +import pytest + +from alphalab.api import ( + CombinedDataset, + combine_datasets, + combined_market_dataset, + ingest_rows, + to_market_dataset, +) +from alphalab.backtesting.dataset import MarketRecord +from alphalab.data import ( + REFUSE_EVERYTHING, + DataAssetClass, + Dataset, + DataValidationError, + IngestionRequest, + PriceBasis, + SourceKind, + TimeFrequency, + raw_source_from_bytes, +) +from alphalab.data.time import BarStamp +from alphalab.market.bar import Bar, TimeFrame +from alphalab.market.normalization import NormalizationPolicy + +DAY = 86_400.0 +FIRST = 1_704_153_600.0 +NORMALIZATION = NormalizationPolicy( + bar_stamp=BarStamp.INTERVAL_END, venue="XNYS", currency="USD", timeframe=TimeFrame.D1 +) + + +def _rows(symbol: str, closes: Sequence[str], offset: float = 0.0) -> list[dict[str, object]]: + return [ + { + "timestamp": str(FIRST + index * DAY + offset), + "symbol": symbol, + "open": close, + "high": close, + "low": close, + "close": close, + "volume": "1000", + } + for index, close in enumerate(closes) + ] + + +def _payload(rows: Sequence[Mapping[str, object]]) -> bytes: + return "\n".join(",".join(str(value) for value in row.values()) for row in rows).encode() + + +def _dataset( + name: str, + rows: Sequence[Mapping[str, object]], + asset_class: DataAssetClass = DataAssetClass.EQUITY, +) -> Dataset: + request = IngestionRequest( + name=name, + source=raw_source_from_bytes( + SourceKind.IN_MEMORY, f"memory://{name}", _payload(rows), FIRST, "text/csv" + ), + frequency=TimeFrequency.DAILY, + bar_stamp=BarStamp.INTERVAL_END, + asset_class=asset_class, + cleaning_policy=REFUSE_EVERYTHING, + price_basis=PriceBasis.RAW, + ) + return ingest_rows(rows, request).dataset + + +EQUITY = _dataset("EQ", _rows("AAA", ["10", "11", "12"])) +FUTURE = _dataset("FUT", _rows("FFF", ["500", "501", "502"], offset=3600.0), DataAssetClass.FUTURE) +SAME_INSTANTS = _dataset("EQ2", _rows("BBB", ["20", "21", "22"])) + + +def _close(record: MarketRecord) -> Decimal: + bar = record.payload + assert isinstance(bar, Bar) + return bar.close + + +def _versions(*datasets: Dataset) -> tuple[str, ...]: + return tuple(dataset.require_provenance().dataset_version for dataset in datasets) + + +def test_the_identity_is_derived_from_the_parts_in_order() -> None: + combined = combine_datasets([(EQUITY, NORMALIZATION), (FUTURE, NORMALIZATION)]) + provenance = combined.require_provenance() + versions = _versions(EQUITY, FUTURE) + hashes = tuple(dataset.require_provenance().content_hash for dataset in (EQUITY, FUTURE)) + + assert provenance.dataset_version == ( + "combined-" + hashlib.sha256("\n".join(versions).encode()).hexdigest() + ) + assert provenance.content_hash == hashlib.sha256("\n".join(hashes).encode()).hexdigest() + assert provenance.parts == tuple(zip(versions, hashes, strict=True)) + assert provenance.source.byte_count == sum( + dataset.require_provenance().source.byte_count for dataset in (EQUITY, FUTURE) + ) + assert combined.dataset_version == provenance.dataset_version + + +def test_another_order_is_another_identity_and_another_tie_break() -> None: + forward = combine_datasets([(EQUITY, NORMALIZATION), (SAME_INSTANTS, NORMALIZATION)]) + backward = combine_datasets([(SAME_INSTANTS, NORMALIZATION), (EQUITY, NORMALIZATION)]) + + assert forward.dataset_version != backward.dataset_version + first = _close(combined_market_dataset(forward).records[0]) + second = _close(combined_market_dataset(backward).records[0]) + assert (first, second) == (Decimal("10"), Decimal("20")) + + +def test_the_stream_is_every_record_in_time_order_with_nothing_aligned() -> None: + combined = combine_datasets([(EQUITY, NORMALIZATION), (FUTURE, NORMALIZATION)]) + market = combined_market_dataset(combined) + + assert market.dataset_id == combined.dataset_version + stamps = [record.timestamp for record in market.records] + assert stamps == sorted(stamps) + # Each series keeps its own instants: the future an hour after the equity. + assert stamps[:2] == [FIRST, FIRST + 3600.0] + assert len(market) == len(to_market_dataset(EQUITY, NORMALIZATION)) + len( + to_market_dataset(FUTURE, NORMALIZATION) + ) + + +def test_combining_is_deterministic() -> None: + parts = [(EQUITY, NORMALIZATION), (FUTURE, NORMALIZATION)] + assert combined_market_dataset(combine_datasets(parts)) == combined_market_dataset( + combine_datasets(parts) + ) + + +def test_one_instruments_price_from_two_parts_at_one_instant_is_refused() -> None: + overlap = _dataset("EQ-COPY", _rows("AAA", ["10"])) + combined = combine_datasets([(EQUITY, NORMALIZATION), (overlap, NORMALIZATION)]) + + with pytest.raises(DataValidationError, match="choose one source"): + combined_market_dataset(combined) + + +def test_one_instrument_from_two_parts_at_different_instants_is_allowed() -> None: + later = _dataset("EQ-LATER", _rows("AAA", ["13"], offset=3 * DAY)) + market = combined_market_dataset( + combine_datasets([(EQUITY, NORMALIZATION), (later, NORMALIZATION)]) + ) + assert [_close(record) for record in market.records] == [ + Decimal(close) for close in ("10", "11", "12", "13") + ] + + +def test_fewer_than_two_parts_or_a_part_twice_is_refused() -> None: + with pytest.raises(DataValidationError, match="two or more"): + combine_datasets([(EQUITY, NORMALIZATION)]) + with pytest.raises(DataValidationError, match="given twice"): + combine_datasets([(EQUITY, NORMALIZATION), (EQUITY, NORMALIZATION)]) + + +def test_a_part_without_provenance_is_refused() -> None: + bare = replace(EQUITY, provenance=None) + + with pytest.raises(DataValidationError): + combine_datasets([(EQUITY, NORMALIZATION), (bare, NORMALIZATION)]) + with pytest.raises(DataValidationError): + CombinedDataset(((EQUITY, NORMALIZATION), (bare, NORMALIZATION))).require_provenance() diff --git a/tests/unit/test_api_macro_lift.py b/tests/unit/test_api_macro_lift.py new file mode 100644 index 00000000..5388d3d9 --- /dev/null +++ b/tests/unit/test_api_macro_lift.py @@ -0,0 +1,98 @@ +"""The macro engine's releases reach a strategy as point-in-time observations (v4.1.1, FUT-008). + +``alphalab.macro`` keeps what a release describes and when it was published +apart; ``observations_from_indicator`` carries both -- and the vintage, numbered +by release order -- into the form the delivery path reads, so a macro-engine +series is scheduled without being re-ingested. +""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from alphalab.alt_data import ObservationSource, delivery_schedule +from alphalab.api import observations_from_indicator +from alphalab.common.point_in_time import VisibilityRule +from alphalab.data import DataValidationError +from alphalab.macro import IndicatorMetadata, IndicatorObservation, known_as_of +from alphalab.macro.enums import Frequency + +CPI = IndicatorMetadata("US_CPI_YOY", "CPI, year on year", "US", Frequency.MONTHLY, "%") +SOURCE = ObservationSource("macro.bls", "2024.06", "c" * 64, 1_718_000_000.0) +MARCH, APRIL = 1_709_251_200.0, 1_711_929_600.0 +RELEASES = ( + IndicatorObservation("US_CPI_YOY", MARCH, 1_712_752_200.0, Decimal("3.5")), + IndicatorObservation("US_CPI_YOY", MARCH, 1_713_961_800.0, Decimal("3.4"), is_revision=True), + IndicatorObservation("US_CPI_YOY", APRIL, 1_715_779_800.0, Decimal("3.7")), +) + + +def test_each_release_is_observed_at_its_period_and_known_at_its_release() -> None: + obs_set = observations_from_indicator(CPI, RELEASES, SOURCE) + schedule = delivery_schedule(obs_set, VisibilityRule.PUBLICATION) + + assert [d.known_at for d in schedule] == [r.release_date for r in RELEASES] + assert [(d.record.revision, d.record.value) for d in schedule] == [ + (0, Decimal("3.5")), + (1, Decimal("3.4")), + (0, Decimal("3.7")), + ] + first = schedule.deliveries[0].record + assert (first.subject, first.metric, first.unit, first.category) == ( + "US", + "us_cpi_yoy", + "%", + "economic", + ) + assert first.stamp.observed_at == MARCH + assert schedule.unverified == 0 + + +def test_what_is_known_at_any_instant_agrees_with_the_macro_engine() -> None: + obs_set = observations_from_indicator(CPI, RELEASES, SOURCE) + schedule = delivery_schedule(obs_set, VisibilityRule.PUBLICATION) + for at in (1_712_752_199.0, 1_712_752_200.0, 1_713_961_800.0, 1_716_000_000.0): + engine = known_as_of(tuple(r for r in RELEASES if r.reference_period == MARCH), at) + lifted = [ + d.record for d in schedule if d.known_at <= at and d.record.stamp.observed_at == MARCH + ] + expected = None if engine is None else engine.value + assert (lifted[-1].value if lifted else None) == expected, at + + +def test_the_set_is_identified_by_its_content() -> None: + first = observations_from_indicator(CPI, RELEASES, SOURCE) + again = observations_from_indicator(CPI, tuple(reversed(RELEASES)), SOURCE) + revised = observations_from_indicator( + CPI, + (*RELEASES[:2], IndicatorObservation("US_CPI_YOY", APRIL, 1_715_779_800.0, Decimal("3.8"))), + SOURCE, + ) + assert first.version == again.version + assert first.version != revised.version + + +@pytest.mark.parametrize( + ("releases", "message"), + [ + ((IndicatorObservation("US_GDP", MARCH, MARCH, Decimal("1")),), "is not one of"), + ((RELEASES[1],), "marked is_revision=True"), + ( + ( + RELEASES[0], + IndicatorObservation("US_CPI_YOY", MARCH, 1_713_961_800.0, Decimal("3.4")), + ), + "is_revision=False", + ), + ((RELEASES[0], RELEASES[0]), "two releases"), + ((), "No release"), + ], + ids=["other-indicator", "first-marked-revision", "later-not-marked", "same-instant", "empty"], +) +def test_a_series_that_contradicts_itself_is_refused( + releases: tuple[IndicatorObservation, ...], message: str +) -> None: + with pytest.raises(DataValidationError, match=message): + observations_from_indicator(CPI, releases, SOURCE) From 65890959507e90be9137bde03c8b5f5f9a981616 Mon Sep 17 00:00:00 2001 From: VarunSingh022 Date: Mon, 26 Oct 2026 18:54:27 +0530 Subject: [PATCH 02/56] feat(execution): a declared queue model for resting limit orders (FUT-011) A backtest or replay given queue= fills a resting limit order only as far as its queue model says has reached the front of its price level. Before this, the order filled as soon as the market touched its limit, as if nothing had been resting there ahead of it. What it adds - alphalab.execution.queue: - QueueModel, a protocol that depends only on core types; - QueueObservation, what one event showed: the quote, the print with its reported aggressor, the bar's range; - QueuePosition, an order's limit and the quantity ahead of it, or unknown; - QueueTurn, the new position and what of the order may fill; - QueueBook, the positions of the orders a model governs; - PriceTimeQueue, the one model shipped. - PriceTimeQueue models FIFO price-time priority over displayed liquidity: - an order joins behind what is shown at the best price, at the front inside the spread, or with an unknown queue behind the best price; - prints at the limit from the other side consume the queue first; - a smaller displayed size bounds the queue, and a larger one never adds to it; - a best price beyond the level empties the queue; - the market trading through the limit clears it; - a touch fills only an order already at the front; - an unsigned print counts only with count_unsigned_trades. - queue= on BacktestEngine.run, ReplayBacktest.run and alphalab.api backtest/replay. The result is a QueuedResult, whose execution_assumptions.queue_model names the model. - ExecutionAssumptions.passive_fills states how passive fills were decided, with or without a model. - ReproducibilityManifest.queue_model records the model. It enters the manifest identity only when present. - A paused replay carries the queue book: replay schema 2 (unreleased) and QUEUE_BOOK_SCHEMA 1. restore(..., queue=) refuses a model that is missing, extra, or configured differently. - The model is refused beside an execution plan (QueueModelError) and on any run that does not route SIMULATED (RuntimeValidationError). How it fits the engine - Only the passive fill is gated. The run's own fill policy decides within the queue's limit, the simulator prices the fill as a maker, and the OMS, portfolio, reservation and attribution settle it as they settle any fill. - The QueueBook is driver state, passed into RunEngine.advance and the pipeline step as a parameter, the way the FX table is. No pipeline, run or config schema moves, and a run without a model keeps its exact record and result_id. The certificate re-certified with only the source digest and the API manifest's digest and export count moved. What it does not model, stated on the ledger and in ADR-0051 decision 15 - Pro-rata books, hidden or iceberg liquidity, depth beyond the top of the book, keep-priority amends and self-trade prevention. - A fill probability. - An execution plan's children. Docs and tests - Docs: the ledger (FUT-011 delivered), ROADMAP, CHANGELOG [Unreleased], ADR-0051, the capability matrix, the gaps report, the readiness report, STATE_MODEL, COMPOSING_STRATEGIES, PUBLIC_API, INTEGRATION and the public API manifest (2,678 exports). - Tests: tests/integration/test_queue_model.py (32) and a manifest test. - Pins extended, each with its reason: the schema-constant lists, the replay field set, the frozen v4.1.0 replay payload's upgrade, the order of the result's fields, and the ADR deferral classifier. Validation (local; this commit's hosted CI is not yet run) - ruff and format are clean (1,313 files); mypy has no issues in 1,211 source files. - Dev checkout: 10,053 passed, 3 failed. - Fresh CPython 3.12.4, 3.13.17 and 3.14.9: 10,054 passed, 2 failed each. - All 75 examples pass. - certify_release --check: 11 checks passed. - Build, twine --strict and preflight passed (611 modules, 2,678 names). - Benchmarks against v4.1.0: no regression. A queue model costs about 9% on a run resting about 150 orders at every one of 6,000 events. - Mutation checks: 15 of 15 caught. - The failures are the two version-keyed guards (test_api_changes_are_in_the_changelog and test_no_ledger_entry_is_assigned_beyond_this_release), plus the dev venv's stale editable metadata (test_package_metadata). The version is unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 65 +- ROADMAP.md | 17 +- alphalab/api.py | 37 +- alphalab/backtesting/__init__.py | 9 +- alphalab/backtesting/engine.py | 36 +- alphalab/backtesting/queueing.py | 167 +++++ alphalab/backtesting/replay.py | 36 +- alphalab/backtesting/replay_snapshot.py | 64 +- alphalab/backtesting/state.py | 40 +- alphalab/execution/__init__.py | 14 + alphalab/execution/queue.py | 308 +++++++++ alphalab/lifecycle/reproducibility.py | 28 + alphalab/runtime/assumptions.py | 57 +- alphalab/runtime/execution_pipeline.py | 215 +++++- alphalab/runtime/run.py | 21 +- ...river-and-the-strategy-capability-audit.md | 42 +- docs/COMPOSING_STRATEGIES.md | 5 +- docs/INTEGRATION.md | 2 +- docs/STATE_MODEL.md | 7 +- docs/api/PUBLIC_API.md | 2 +- docs/api/public_api.json | 35 +- docs/audit/FUTURE_READINESS_V4_1_1.md | 70 +- docs/audit/PRE_V4_COMPLETION_LEDGER.yaml | 29 +- docs/audit/RELEASE_CERTIFICATION.md | 6 +- docs/audit/STRATEGY_CAPABILITY_MATRIX.md | 10 +- .../STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md | 15 +- docs/audit/release_certification.json | 6 +- tests/integration/test_queue_model.py | 610 ++++++++++++++++++ tests/regression/test_currency_authority.py | 10 +- .../test_documented_schemas_are_current.py | 3 + .../test_every_adr_deferral_is_classified.py | 4 +- .../test_replay_pause_and_resume.py | 19 +- tests/regression/test_v37_invariants.py | 3 +- tests/regression/test_v38_invariants.py | 3 +- tests/unit/lifecycle/test_reproducibility.py | 53 +- 35 files changed, 1889 insertions(+), 159 deletions(-) create mode 100644 alphalab/backtesting/queueing.py create mode 100644 alphalab/execution/queue.py create mode 100644 tests/integration/test_queue_model.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fd7cf053..02488fc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,9 @@ releases and fitted models -- every timed input applied at its instant on every driver, contracts that expire, are exercised, assigned and delivered by their declared terms in any market a calendar describes, bonds that pay coupons and redeem, orders worked in children by execution algorithms over historical bars, -a paused replay that keeps all of it durably -- and a source-derived audit of what +resting limit orders that fill only once a declared queue model puts them at the +front of their price level, a paused replay that keeps all of it durably -- and a +source-derived audit of what AlphaLab supports from research to production.** Additive on the v4.1 contract: against v4.1.0's manifest it adds names, gives @@ -29,8 +31,8 @@ existing functions keyword-only parameters with no effect unless given, adds `AssetType.BOND`, `AssetType.INDEX` and `ExecutionRouting.ALGORITHMIC`, and removes or re-signs nothing incompatibly. Two schemas step, each reading every earlier payload: instrument 3 -> 4 (a last trading instant -and contract terms) and replay 1 -> 2 (the schedule's digest and an execution -plan's book). No run, pipeline or portfolio schema +and contract terms) and replay 1 -> 2 (the schedule's digest, an execution +plan's book and a queue model's book). No run, pipeline or portfolio schema moves, and a run of v4.1.0's inputs has the identity it had. Because it adds public API it is a minor release under `docs/api/PUBLIC_API.md`'s policy -- see `docs/audit/FUTURE_READINESS_V4_1_1.md`. ADR-0051 records the decisions; ledger @@ -103,6 +105,30 @@ IDs are in brackets. a reference value never traded. `alphalab.runtime.session_close` and `settles_at` read a lifecycle's instants from a declared market calendar and settlement rule. +* **A queue model for resting limit orders** [FUT-011]. + `alphalab.execution.queue`: a `QueueModel` keeps each resting limit order's + `QueuePosition` -- the quantity ahead of it at its limit -- from what each + event showed (`QueueObservation`: the quote, the print and its reported + aggressor, the bar's range), and answers each turn with a `QueueTurn`: the + new position and what of the order may fill. `PriceTimeQueue` models + price-time priority over displayed liquidity: an order joins behind what was + shown at its price, or at the front inside the spread; prints at the limit by + the other side consume the queue first; a shrinking displayed level bounds + it; the market trading through the limit clears it; a touch fills only an + order at the front; an unsigned print counts only with + `count_unsigned_trades`. Given to `BacktestEngine.run(..., queue=)`, + `ReplayBacktest.run(..., queue=)` or `alphalab.api.backtest`/`replay`, it + bounds which passive fills happen -- the fill policy, simulator, costs, OMS, + portfolio and attribution are the run's own -- and the result is a + `QueuedResult` whose `execution_assumptions.queue_model` names it + (`ExecutionAssumptions.passive_fills` states how passive fills were decided, + with or without one). The `QueueBook` is driver state threaded through + `RunEngine.advance(..., queue=)` and `ExecutionPipeline.process_record` / + `process_market_event(..., queue=)`, so no run's record or `result_id` + moves; a paused replay keeps it (`alphalab.backtesting.queueing`, + `QUEUE_BOOK_SCHEMA = 1`; `replay_snapshot.restore(..., queue=)`), and + `manifest_for_run` records the model as `ReproducibilityManifest.queue_model`. + Refused beside an execution plan and on a run that routes to a venue. * **The macro engine's series as observations** [FUT-008]. `alphalab.api.observations_from_indicator`. * **`LiveSession.deliver_observation`** [OFE-027]. @@ -133,27 +159,32 @@ IDs are in brackets. | Name | Change | | --- | --- | -| `alphalab.api.backtest` | keyword-only `observations`, `schedule` and `execution`, empty or `None` by default: no effect unless given | -| `alphalab.api.replay` | keyword-only `observations`, `schedule` and `execution` | -| `alphalab.backtesting.PausedReplay` | fields `schedule`, empty by default, and `algorithms`, `None` by default | -| `alphalab.backtesting.ReplayResult` | property `algorithms` | -| `alphalab.backtesting.ReplayBacktest.run` | keyword-only `observations`, `schedule` and `execution` | -| `alphalab.backtesting.BacktestEngine.run` | keyword-only `schedule` and `execution` | -| `alphalab.backtesting.replay_snapshot.restore` | `schedule`, empty by default, and `execution`, `None` by default; `ReplaySnapshot.schedule_digest` and `algorithms` | +| `alphalab.api.backtest` | keyword-only `observations`, `schedule`, `execution` and `queue`, empty or `None` by default: no effect unless given | +| `alphalab.api.replay` | keyword-only `observations`, `schedule`, `execution` and `queue` | +| `alphalab.backtesting.PausedReplay` | fields `schedule`, empty by default, and `algorithms` and `queue`, `None` by default | +| `alphalab.backtesting.ReplayResult` | properties `algorithms` and `queue` | +| `alphalab.backtesting.ReplayBacktest.run` | keyword-only `observations`, `schedule`, `execution` and `queue` | +| `alphalab.backtesting.BacktestEngine.run` | keyword-only `schedule`, `execution` and `queue` | +| `alphalab.backtesting.replay_snapshot.restore` | `schedule`, empty by default, and `execution` and `queue`, `None` by default; `ReplaySnapshot.schedule_digest`, `algorithms` and `queue` | +| `alphalab.runtime.RunEngine.advance`, `ExecutionPipeline.process_record`, `process_market_event` | keyword-only `queue`, `None` by default; `ExecutionPipelineResult.queue`, appended last, `None` by default | +| `alphalab.runtime.ExecutionAssumptions`, `execution_assumptions` | field `queue_model` and trailing parameter `queue_model`, `None` by default; property `passive_fills`; `settings()` adds `execution.queue_model` only when a model is declared | | `alphalab.runtime.execution_pipeline.ExecutionRouting` | member `ALGORITHMIC`; `ExecutionMode.works_children` | | `alphalab.core.enums.AssetType` | members `BOND` and `INDEX` | | `alphalab.instrument.record.InstrumentRecord` | fields `last_trade_at` and `terms`, `None` by default; `InstrumentRegistry.expiring`, a derived index | -| `alphalab.lifecycle.manifest_for_run`, `canonical_manifest_key`, `derive_manifest_id` | keyword or trailing `schedule` / `schedule_digest` (`None`) and `dataset_parts` (empty) by default, and `execution` / `execution_plan_id` (`None`); `ReproducibilityManifest.schedule_digest`, `dataset_parts` and `execution_plan_id` | +| `alphalab.lifecycle.manifest_for_run`, `canonical_manifest_key`, `derive_manifest_id` | keyword or trailing `schedule` / `schedule_digest` (`None`) and `dataset_parts` (empty) by default, `execution` / `execution_plan_id` and `queue_model` (`None`); `ReproducibilityManifest.schedule_digest`, `dataset_parts`, `execution_plan_id` and `queue_model` | | `alphalab.portfolio.engine.PortfolioEngine.apply_fill` | keyword-only `final_settlement`, `False` by default | -| `alphalab.backtesting.schedule` (with `ScheduledExercise`, `ScheduledAssignment`, `ScheduledExpiry` and `RunSchedule.of(exercises=, assignments=, expiries=)`), `alphalab.backtesting.algorithmic` and `alphalab.instrument.terms` and their names; `alphalab.portfolio.Exercise`, `Assignment`, `OptionExpiry`; `RunEngine.exercise`, `assign`, `expire_option`; `ExecutionPipeline.exercise`, `assign`, `expire_option`; `AllocationEngine.shares_of`, `apply_lifecycle`; `alphalab.runtime.session_close`, `settles_at`; `AlgorithmicResult`, `ExecutionPipeline.work_child`, `ChildTurn`; `alphalab.portfolio.ContractSettlement`; `alphalab.api.CombinedDataset`, `CombinedProvenance`, `CombinedSourceBytes`, `combine_datasets`, `combined_market_dataset`, `observations_from_indicator`, `bond_schedule`; `RunEngine.fund`, `RunEngine.settle_contract`, `ExecutionPipeline.settle_contract`, `AllocationEngine.settle_asset`, `LiveSession.deliver_observation`, `held_past_expiry` | added | +| `alphalab.execution.queue` (`PriceTimeQueue`, `QueueBook`, `QueueModel`, `QueueObservation`, `QueuePosition`, `QueueTurn`) and `alphalab.backtesting.queueing` (`QUEUE_BOOK_SCHEMA`, `QueueModelError`, `capture_queue`, `queue_from_primitives`, `queued_result`, `queued_run`); `alphalab.backtesting.QueuedResult`; `alphalab.backtesting.schedule` (with `ScheduledExercise`, `ScheduledAssignment`, `ScheduledExpiry` and `RunSchedule.of(exercises=, assignments=, expiries=)`), `alphalab.backtesting.algorithmic` and `alphalab.instrument.terms` and their names; `alphalab.portfolio.Exercise`, `Assignment`, `OptionExpiry`; `RunEngine.exercise`, `assign`, `expire_option`; `ExecutionPipeline.exercise`, `assign`, `expire_option`; `AllocationEngine.shares_of`, `apply_lifecycle`; `alphalab.runtime.session_close`, `settles_at`; `AlgorithmicResult`, `ExecutionPipeline.work_child`, `ChildTurn`; `alphalab.portfolio.ContractSettlement`; `alphalab.api.CombinedDataset`, `CombinedProvenance`, `CombinedSourceBytes`, `combine_datasets`, `combined_market_dataset`, `observations_from_indicator`, `bond_schedule`; `RunEngine.fund`, `RunEngine.settle_contract`, `ExecutionPipeline.settle_contract`, `AllocationEngine.settle_asset`, `LiveSession.deliver_observation`, `held_past_expiry` | added | ## Accepted as future work -FUT-011 (a queue model), FUT-014 (a strategy-facing exercise instruction), -FUT-015 (settlement-date accounting), FUT-016 (richer contract terms) and FUT-017 -(margin and forced liquidation), each with its limitation, interim behaviour and -acceptance criteria on `ROADMAP.md`. FUT-009 and FUT-013, accepted by the audit -and the continuation, were delivered by the candidate's last two milestones. +FUT-014 (a strategy-facing exercise instruction), FUT-015 (settlement-date +accounting), FUT-016 (richer contract terms) and FUT-017 (margin and forced +liquidation), each with its limitation, interim behaviour and acceptance criteria +on `ROADMAP.md`. FUT-009, FUT-011 and FUT-013, accepted by the audit and the +continuation, were delivered by the candidate's last three milestones; FUT-011's +model is price-time priority over displayed liquidity, and what it does not +model -- pro-rata books, hidden liquidity, a fill probability -- is stated on +the ledger entry. --- diff --git a/ROADMAP.md b/ROADMAP.md index 56c852f1..ff2b8c58 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -189,6 +189,15 @@ names what the release established and the ADR that records the decision. run's schedule through the portfolio's fill authority; `AssetType.INDEX`; lifecycle instants from a declared market calendar (`session_close`, `settles_at`); example 75. +- **A queue model for resting limit orders** (the audit's queue-position + proposal): `alphalab.execution.queue.PriceTimeQueue` on `BacktestEngine.run`, + `ReplayBacktest.run` and the facade (`queue=`) -- each resting limit order keeps + its place at its price level from the quote, the signed prints and the bar's + range, and fills only as far as reached the front, through the run's own fill + policy, costs and books; named in the result's execution assumptions and the + run's manifest, kept by a paused replay. Price-time priority over displayed + liquidity only: pro-rata books, hidden liquidity and a fill probability are + other models of the same protocol. # Deliberate boundaries @@ -558,14 +567,6 @@ The entries below were proposed by the v4.1.1 future-readiness audit horizon, is the maintainer's decision. The candidate itself delivered the rest of what the audit proposed (section *v4.2.0 candidate*, above). -## FUT-011 — a queue-position and fill-probability model (target v5.0) - -- **Limitation.** A resting limit order fills by the simulator's rule. -- **Interim behaviour.** Treat passive fills as optimistic in any conclusion. -- **Acceptance criteria.** A declared queue model decides passive fills and is - named in the result's execution assumptions. **Dependencies.** depth data; - BDY-003. - ## FUT-014 — a strategy that instructs an exercise from its hooks (target v4.3) - **Limitation.** An exercise is a scheduled input or a `RunEngine` call; no diff --git a/alphalab/api.py b/alphalab/api.py index ad63d321..4838675e 100644 --- a/alphalab/api.py +++ b/alphalab/api.py @@ -131,7 +131,7 @@ ScheduledDistribution, ScheduledSettlement, ) -from alphalab.backtesting.state import AlgorithmicResult, BacktestResult, ReplayResult +from alphalab.backtesting.state import AlgorithmicResult, BacktestResult, QueuedResult, ReplayResult from alphalab.common.exceptions import AlphaLabValidationError from alphalab.common.point_in_time import PointInTimeStamp from alphalab.conventions.lot import LotSpecification @@ -171,6 +171,7 @@ ValidationFinding, validate_records, ) +from alphalab.execution.queue import QueueModel from alphalab.factor_library.compute import compute_panel from alphalab.factor_library.definition import FeatureField from alphalab.factor_library.forward_returns import ( @@ -694,6 +695,7 @@ def backtest( observations: Iterable[ObservationDelivery[Any]] = (), schedule: RunSchedule = EMPTY_SCHEDULE, execution: None = None, + queue: None = None, ) -> BacktestResult: ... @@ -708,9 +710,25 @@ def backtest( observations: Iterable[ObservationDelivery[Any]] = (), schedule: RunSchedule = EMPTY_SCHEDULE, execution: ExecutionPlan, + queue: None = None, ) -> AlgorithmicResult: ... +@overload +def backtest( + config: RunConfig, + dataset: Dataset, + strategy_state: StrategyRuntimeState, + context_factory: ContextFactory, + policy: NormalizationPolicy, + *, + observations: Iterable[ObservationDelivery[Any]] = (), + schedule: RunSchedule = EMPTY_SCHEDULE, + execution: None = None, + queue: QueueModel, +) -> QueuedResult: ... + + @overload def backtest( config: RunConfig, @@ -722,6 +740,7 @@ def backtest( observations: Iterable[ObservationDelivery[Any]] = (), schedule: RunSchedule = EMPTY_SCHEDULE, execution: ExecutionPlan | None, + queue: QueueModel | None = None, ) -> BacktestResult: ... @@ -735,6 +754,7 @@ def backtest( observations: Iterable[ObservationDelivery[Any]] = (), schedule: RunSchedule = EMPTY_SCHEDULE, execution: ExecutionPlan | None = None, + queue: QueueModel | None = None, ) -> BacktestResult: """Run a backtest over a canonical dataset, carrying its identity into the result. @@ -754,7 +774,10 @@ def backtest( ``execution`` (v4.1.1, ledger FUT-009) works every order in children by an execution algorithm, and the result is then an :class:`~alphalab.backtesting.AlgorithmicResult` carrying the book; see - :mod:`alphalab.backtesting.algorithmic`. + :mod:`alphalab.backtesting.algorithmic`. ``queue`` (v4.1.1, ledger + FUT-011) governs resting limit orders by a queue model, and the result is + then a :class:`~alphalab.backtesting.QueuedResult` naming it; see + :mod:`alphalab.backtesting.queueing`. """ return BacktestEngine.run( @@ -765,6 +788,7 @@ def backtest( observations, schedule=schedule, execution=execution, + queue=queue, ) @@ -779,6 +803,7 @@ def replay( observations: Iterable[ObservationDelivery[Any]] = (), schedule: RunSchedule = EMPTY_SCHEDULE, execution: ExecutionPlan | None = None, + queue: QueueModel | None = None, ) -> ReplayResult: ... @@ -794,6 +819,7 @@ def replay( observations: Iterable[ObservationDelivery[Any]] = (), schedule: RunSchedule = EMPTY_SCHEDULE, execution: ExecutionPlan | None = None, + queue: QueueModel | None = None, ) -> ReplayResult | PausedReplay: ... @@ -808,6 +834,7 @@ def replay( observations: Iterable[ObservationDelivery[Any]] = (), schedule: RunSchedule = EMPTY_SCHEDULE, execution: ExecutionPlan | None = None, + queue: QueueModel | None = None, ) -> ReplayResult | PausedReplay: """Replay a canonical dataset through the execution path, with the same identity. @@ -823,7 +850,9 @@ def replay( records the schedule's digest, and restoring it takes the schedule back (ledger FUT-003). ``execution`` (v4.1.1, ledger FUT-009) works every order in children, as :func:`backtest` does; restoring a paused replay takes the - plan back. + plan back. ``queue`` (v4.1.1, ledger FUT-011) governs resting limit orders + by a queue model, as :func:`backtest` does; restoring a paused replay takes + the model back. """ market = to_market_dataset(dataset, policy) @@ -836,6 +865,7 @@ def replay( observations=observations, schedule=schedule, execution=execution, + queue=queue, ) return ReplayBacktest.run( config, @@ -846,6 +876,7 @@ def replay( observations=observations, schedule=schedule, execution=execution, + queue=queue, ) diff --git a/alphalab/backtesting/__init__.py b/alphalab/backtesting/__init__.py index 680d3272..370a458b 100644 --- a/alphalab/backtesting/__init__.py +++ b/alphalab/backtesting/__init__.py @@ -54,6 +54,7 @@ DatasetValidationError, UnsupportedRecordError, ) +from alphalab.backtesting.queueing import QueueModelError from alphalab.backtesting.replay import ( PausedReplay, ReplayBacktest, @@ -75,7 +76,7 @@ ScheduledSplit, ScheduleValidationError, ) -from alphalab.backtesting.state import AlgorithmicResult, BacktestResult, ReplayResult +from alphalab.backtesting.state import AlgorithmicResult, BacktestResult, QueuedResult, ReplayResult from alphalab.backtesting.views import ( commission_paid, equity_values, @@ -96,6 +97,7 @@ LiquidityContext, StaticFill, ) +from alphalab.execution.queue import PriceTimeQueue, QueueBook, QueueModel from alphalab.runtime.execution_pipeline import UnpricedAsset, UnpricedReason from alphalab.runtime.run import ExecutionMode, RunConfig, RunState, RunStep @@ -123,6 +125,11 @@ "MarketInput", "MarketRecord", "PausedReplay", + "PriceTimeQueue", + "QueueBook", + "QueueModel", + "QueueModelError", + "QueuedResult", "ReplayBacktest", "ReplayProgress", "ReplayResult", diff --git a/alphalab/backtesting/engine.py b/alphalab/backtesting/engine.py index ff7ff87c..d866f8f1 100644 --- a/alphalab/backtesting/engine.py +++ b/alphalab/backtesting/engine.py @@ -40,10 +40,12 @@ from alphalab.backtesting.algorithmic import finish as finish_children from alphalab.backtesting.algorithmic import work as work_children from alphalab.backtesting.dataset import MarketDataset, MarketRecord +from alphalab.backtesting.queueing import queued_result, queued_run from alphalab.backtesting.schedule import EMPTY_SCHEDULE, RunSchedule, ScheduleCursor -from alphalab.backtesting.state import AlgorithmicResult, BacktestResult +from alphalab.backtesting.state import AlgorithmicResult, BacktestResult, QueuedResult from alphalab.common.evolve import evolve from alphalab.common.ids import id_scope, id_source +from alphalab.execution.queue import QueueModel from alphalab.market.state import MarketState from alphalab.runtime.execution_pipeline import ContextFactory, ExecutionPipelineResult from alphalab.runtime.run import ExecutionMode, RunConfig, RunEngine, RunState @@ -190,6 +192,7 @@ def run( *, schedule: RunSchedule = EMPTY_SCHEDULE, execution: None = None, + queue: None = None, ) -> BacktestResult: ... @overload @@ -203,8 +206,23 @@ def run( *, schedule: RunSchedule = EMPTY_SCHEDULE, execution: ExecutionPlan, + queue: None = None, ) -> AlgorithmicResult: ... + @overload + @staticmethod + def run( + config: RunConfig, + dataset: MarketDataset, + strategy_state: StrategyRuntimeState, + context_factory: ContextFactory, + observations: Iterable[ObservationDelivery[Any]] = (), + *, + schedule: RunSchedule = EMPTY_SCHEDULE, + execution: None = None, + queue: QueueModel, + ) -> QueuedResult: ... + @overload @staticmethod def run( @@ -216,6 +234,7 @@ def run( *, schedule: RunSchedule = EMPTY_SCHEDULE, execution: ExecutionPlan | None, + queue: QueueModel | None = None, ) -> BacktestResult: ... @staticmethod @@ -228,6 +247,7 @@ def run( *, schedule: RunSchedule = EMPTY_SCHEDULE, execution: ExecutionPlan | None = None, + queue: QueueModel | None = None, ) -> BacktestResult: """Run ``dataset`` end to end and return the finished result. @@ -257,9 +277,17 @@ def run( asset's events, after the run's own step at each -- see :mod:`alphalab.backtesting.algorithmic`. The result is then an :class:`~alphalab.backtesting.state.AlgorithmicResult`, carrying the book. + + ``queue`` (v4.1.1, ledger FUT-011) is a queue model: each resting limit + order keeps its place at its price level and fills only as far as the + model says has reached the front -- see + :mod:`alphalab.backtesting.queueing`. The result is then a + :class:`~alphalab.backtesting.state.QueuedResult`, whose execution + assumptions name the model. Refused beside ``execution``. """ config, book = algorithmic_run(config, execution, ExecutionMode.BACKTEST) + queued = queued_run(config, queue) cursor = ScheduleCursor.start(schedule.with_observations(observations)) with id_scope(config.seed): state = evolve( @@ -275,12 +303,16 @@ def run( state, context_factory, cursor.rates, before=record.timestamp ) state, result = RunEngine.advance( - state, record, context_factory, rates=cursor.rates + state, record, context_factory, rates=cursor.rates, queue=queued ) + if result is not None and result.queue is not None: + queued = result.queue if book is not None and result is not None: book, state = work_children(book, state, result.market_event, cursor.rates) cursor, state = cursor.finish(state, context_factory, dataset.records[-1].timestamp) state = RunEngine.close_slice(state, context_factory, cursor.rates) + if queued is not None: + return queued_result(state, queued) if book is None: return finalize(state) book, state = finish_children(book, state) diff --git a/alphalab/backtesting/queueing.py b/alphalab/backtesting/queueing.py new file mode 100644 index 00000000..b5819905 --- /dev/null +++ b/alphalab/backtesting/queueing.py @@ -0,0 +1,167 @@ +"""A queue model on the historical drivers: which resting limit orders reached the front. + +Without a queue model a backtest fills a resting limit order when the market +reaches its limit, as if it had been first in line there -- optimistic for every +passive order, and most of all for a market maker's (ledger FUT-011). A +:class:`~alphalab.execution.queue.QueueModel` given to +:meth:`BacktestEngine.run ` or +:meth:`ReplayBacktest.run ` keeps +each resting limit order's place at its price level, and the order fills only as +far as the model says has reached the front of it. Everything else about the run +is unchanged. + +The path +-------- +:: + + strategy intent -> allocation -> risk -> OMS: a limit order, accepted + -> its first turn, as before: it takes the market if that is within its + limit, or rests + -> resting: it joins its level's queue, from the event it was placed at + -> at each later event of its asset, before the strategy decides there: + the queue model advances its position from what the event showed, + then the run's fill policy decides its fill within what the model + allows; the simulator prices it at the limit, as a maker + -> the fill settled as every fill is: OMS, portfolio, reservation, + attribution, feedback + +The queue book is driver state, threaded through +:meth:`RunEngine.advance ` as an +execution plan's book is, so the run's record and ``result_id`` are what they +would be without it, and :class:`~alphalab.backtesting.state.QueuedResult` names +the model in its execution assumptions. A queue model works simulated orders +only: it is refused beside an execution plan, whose children it would not see, +and on a run that routes to a venue. + +What is governed, and what is not +--------------------------------- +A limit order, or a stop-limit once triggered, that is neither immediate nor an +auction order. A market order, an immediate-or-cancel or fill-or-kill order and +an auction order never rest in a queue, and are worked exactly as before. An +order placed outside a market event of its asset -- by a timer, a slice or an +observation -- has no event to join at, and starts from an unknown queue: it +fills only once the market trades through its limit or its level is shown to be +empty. A cancelled, expired or filled order leaves the book; the simulated path +has no amend, so a changed order is a new one and joins at the back. + +Persistence +----------- +:func:`capture_queue` writes a book as plain primitives with its model's type +and configuration, and :func:`queue_from_primitives` reads it back given the +model -- refused if it is another. A paused replay carries its book in its +payload. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from decimal import Decimal +from typing import Any, Final + +from alphalab.backtesting.exceptions import BacktestError +from alphalab.backtesting.state import QueuedResult +from alphalab.common.persistent_map import PersistentMap +from alphalab.execution.queue import QueueBook, QueueModel, QueuePosition +from alphalab.runtime.assumptions import describe +from alphalab.runtime.execution_pipeline import ExecutionRouting +from alphalab.runtime.run import RunConfig, RunEngine, RunState + +__all__ = [ + "QUEUE_BOOK_SCHEMA", + "QueueModelError", + "capture_queue", + "queue_from_primitives", + "queued_result", + "queued_run", +] + +#: The version of the primitives :func:`capture_queue` writes. +QUEUE_BOOK_SCHEMA: Final = 1 + + +class QueueModelError(BacktestError): + """A queue model, or a queue book written under one, that cannot be used as given.""" + + +def queued_run(config: RunConfig, model: QueueModel | None) -> QueueBook | None: + """The empty book a run under ``model`` starts with, or ``None`` without one. + + Raises: + QueueModelError: If the run does not simulate its orders -- it routes + to a venue, or works them in an execution plan's children. + """ + + if model is None: + return None + routing = config.pipeline.routing + if routing is not ExecutionRouting.SIMULATED: + raise QueueModelError( + f"A queue model governs simulated resting orders, and this run routes " + f"{routing.name}: " + + ( + "an execution plan's children are not governed by it. Give the run one or " + "the other." + if routing is ExecutionRouting.ALGORITHMIC + else "its orders are its venue's to fill." + ) + ) + return QueueBook(model) + + +def queued_result(state: RunState, queue: QueueBook) -> QueuedResult: + """The finished run and its book, holding the orders still resting when it ended. + + An order the run ended after its asset's last event -- a settlement, a + split, an expiry the data's end reached -- leaves the book here. + """ + + working = (str(order_id.value) for order_id in state.pipeline.oms.active_orders) + return QueuedResult(run=RunEngine.finalize(state), queue=queue.keeping(working)) + + +def capture_queue(book: QueueBook) -> dict[str, Any]: + """The book as plain primitives, for a payload. Read back by :func:`queue_from_primitives`.""" + + return { + "schema_version": QUEUE_BOOK_SCHEMA, + "model_type": type(book.model).__name__, + "model": describe(book.model), + "positions": [ + [ + order_id, + str(position.limit_price), + None if position.ahead is None else str(position.ahead), + ] + for order_id, position in book.positions.items() + ], + } + + +def queue_from_primitives(payload: Mapping[str, Any], model: QueueModel) -> QueueBook: + """Read a book :func:`capture_queue` wrote, under the model it was produced by. + + Raises: + QueueModelError: If the schema version is not this build's, or + ``model`` is not the model -- the type and configuration -- the book + was produced under. + """ + + version = payload.get("schema_version") + if version != QUEUE_BOOK_SCHEMA: + raise QueueModelError( + f"A queue book of schema {version!r}; this build reads {QUEUE_BOOK_SCHEMA}." + ) + recorded = payload["model"] + supplied = describe(model) + if payload["model_type"] != type(model).__name__ or recorded != supplied: + raise QueueModelError( + f"The queue book was produced under {recorded} and {supplied} was supplied; " + "continuing it would decide the resting orders' fills another way." + ) + positions: PersistentMap[str, QueuePosition] = PersistentMap() + for order_id, limit_price, ahead in payload["positions"]: + positions = positions.set( + str(order_id), + QueuePosition(Decimal(limit_price), None if ahead is None else Decimal(ahead)), + ) + return QueueBook(model, positions) diff --git a/alphalab/backtesting/replay.py b/alphalab/backtesting/replay.py index 9ff2b649..950a6678 100644 --- a/alphalab/backtesting/replay.py +++ b/alphalab/backtesting/replay.py @@ -108,9 +108,11 @@ initialize, ) from alphalab.backtesting.exceptions import UnsupportedRecordError +from alphalab.backtesting.queueing import queued_result, queued_run from alphalab.backtesting.schedule import EMPTY_SCHEDULE, RunSchedule, ScheduleCursor from alphalab.backtesting.state import AlgorithmicResult, BacktestResult, ReplayResult from alphalab.common.ids import IdStreamPosition, current_id_position, use_id_source +from alphalab.execution.queue import QueueBook, QueueModel from alphalab.replay.engine import ReplayEngine from alphalab.replay.exceptions import InvalidReplayStateError from alphalab.replay.session import ReplaySession @@ -210,6 +212,9 @@ class PausedReplay: algorithms: The execution plan's book, for a replay given one (v4.1.1, ledger FUT-009): every parent's algorithm as it stood at the pause, the children still working included; ``None`` otherwise. + queue: The queue book, for a replay given a queue model (v4.1.1, ledger + FUT-011): the model, and each resting limit order's place at its + price level at the pause; ``None`` otherwise. Raises: InvalidReplayStateError: If the three do not describe one replay paused @@ -223,6 +228,7 @@ class PausedReplay: cursor_position: IdStreamPosition schedule: RunSchedule = EMPTY_SCHEDULE algorithms: AlgorithmBook | None = None + queue: QueueBook | None = None def __post_init__(self) -> None: cursor = self.cursor @@ -296,6 +302,7 @@ def run( observations: Iterable[ObservationDelivery[Any]] = (), schedule: RunSchedule = EMPTY_SCHEDULE, execution: ExecutionPlan | None = None, + queue: QueueModel | None = None, ) -> ReplayResult: ... @overload @@ -310,6 +317,7 @@ def run( observations: Iterable[ObservationDelivery[Any]] = (), schedule: RunSchedule = EMPTY_SCHEDULE, execution: ExecutionPlan | None = None, + queue: QueueModel | None = None, ) -> ReplayResult | PausedReplay: ... @staticmethod @@ -323,6 +331,7 @@ def run( observations: Iterable[ObservationDelivery[Any]] = (), schedule: RunSchedule = EMPTY_SCHEDULE, execution: ExecutionPlan | None = None, + queue: QueueModel | None = None, ) -> ReplayResult | PausedReplay: """Replay ``dataset`` through the execution path, to its end or to a pause. @@ -350,9 +359,15 @@ def run( ``execution`` (v4.1.1, ledger FUT-009) works every order in children, as :meth:`BacktestEngine.run ` does; a pause keeps the book, and a resume continues every algorithm. + + ``queue`` (v4.1.1, ledger FUT-011) governs resting limit orders by a + queue model, as :meth:`BacktestEngine.run + ` does; a pause keeps + every order's place, and a resume continues from it. """ config, book = algorithmic_run(config, execution, ExecutionMode.REPLAY) + queued = queued_run(config, queue) cursor_ids = id_source(cursor_seed(config.seed)) with use_id_source(cursor_ids): @@ -371,6 +386,7 @@ def run( pause_when, schedule.with_observations(observations), book, + queued, resumed=False, ) @@ -428,6 +444,7 @@ def resume( pause_when, paused.schedule, paused.algorithms, + paused.queue, resumed=True, ) @@ -440,6 +457,7 @@ def _drive( pause_when: Callable[[ReplayProgress], bool] | None, schedule: RunSchedule, book: AlgorithmBook | None, + queue: QueueBook | None, *, resumed: bool, ) -> ReplayResult | PausedReplay: @@ -450,7 +468,8 @@ def _drive( ``schedule`` is applied by the backtest's own cursor, record by record, started where the last record the cursor took leaves it; ``book`` works every parent at its asset's events, after the run's step at each, as the - backtest does. + backtest does; ``queue`` is threaded through each step, as the backtest + threads it. """ total = len(replay.events) @@ -485,7 +504,10 @@ def _drive( with use_id_source(cursor_ids): paused = ReplayEngine.pause(replay, float(replayed)) position = current_id_position() - return PausedReplay(state, paused, position, schedule, book) + if queue is not None: + working = (str(oid.value) for oid in state.pipeline.oms.active_orders) + queue = queue.keeping(working) + return PausedReplay(state, paused, position, schedule, book, queue) ask = True with use_id_source(cursor_ids): @@ -504,7 +526,11 @@ def _drive( ) inputs, state = inputs.before(state, record.timestamp, context_factory) state = RunEngine.close_slice(state, context_factory, inputs.rates, before=record.timestamp) - state, result = RunEngine.advance(state, record, context_factory, rates=inputs.rates) + state, result = RunEngine.advance( + state, record, context_factory, rates=inputs.rates, queue=queue + ) + if result is not None and result.queue is not None: + queue = result.queue if book is not None and result is not None: book, state = work_children(book, state, result.market_event, inputs.rates) # The cursor's lifecycle log is an audit trail like any subsystem's, and @@ -518,7 +544,9 @@ def _drive( inputs, state = inputs.finish(state, context_factory, None if last is None else last.timestamp) state = RunEngine.close_slice(state, context_factory, inputs.rates) backtest: BacktestResult - if book is None: + if queue is not None: + backtest = queued_result(state, queue) + elif book is None: backtest = finalize(state) else: book, state = finish_children(book, state) diff --git a/alphalab/backtesting/replay_snapshot.py b/alphalab/backtesting/replay_snapshot.py index 60d413bf..87b9a2be 100644 --- a/alphalab/backtesting/replay_snapshot.py +++ b/alphalab/backtesting/replay_snapshot.py @@ -54,11 +54,13 @@ capture_book, ) from alphalab.backtesting.dataset import MarketDataset +from alphalab.backtesting.queueing import QueueModelError, capture_queue, queue_from_primitives from alphalab.backtesting.replay import PausedReplay, cursor_seed from alphalab.backtesting.schedule import EMPTY_SCHEDULE, RunSchedule from alphalab.common.append_log import AppendOnlyLog from alphalab.common.constants import DEFAULT_ENCODING from alphalab.common.ids import IdStreamPosition +from alphalab.execution.queue import QueueBook, QueueModel from alphalab.persistence.decode import ( as_float, as_int, @@ -105,16 +107,17 @@ #: Schema version this module reads and writes; a module-local literal for the #: reason ``RUN_SNAPSHOT_SCHEMA`` is one. Version 1 was v4.1's; version 2 #: (v4.1.1, ledger FUT-003) records the digest of the replay's schedule and, for -#: a replay given an execution plan (FUT-009), its algorithm book. +#: a replay given an execution plan (FUT-009), its algorithm book, and for one +#: given a queue model (FUT-011), its queue book. REPLAY_SNAPSHOT_SCHEMA: Final = 2 _SUBSYSTEM: Final = "replay" def _v1_to_v2(payload: dict[str, Any]) -> dict[str, Any]: - """A version-1 replay was given no schedule and no plan: v4.1 replays took neither.""" + """A version-1 replay was given no schedule, plan or queue model: v4.1 replays took none.""" - return {**payload, "schedule_digest": None, "algorithms": None} + return {**payload, "schedule_digest": None, "algorithms": None, "queue": None} #: How every replay payload a release has written is read by this one. @@ -124,8 +127,8 @@ def _v1_to_v2(payload: dict[str, Any]) -> dict[str, Any]: ( SchemaStep( 1, - "version 2 records the digest of the replay's schedule and its algorithm book; " - "a v4.1 replay had neither", + "version 2 records the digest of the replay's schedule, its algorithm book and " + "its queue book; a v4.1 replay had none of them", upgrade=_v1_to_v2, ), ), @@ -175,6 +178,10 @@ class ReplaySnapshot: :func:`~alphalab.backtesting.algorithmic.capture_book` writes it, or ``None`` for a replay given no execution plan (v4.1.1, schema 2). The plan is supplied back to :func:`restore` and held to the book. + queue: The replay's :class:`~alphalab.execution.queue.QueueBook` as + :func:`~alphalab.backtesting.queueing.capture_queue` writes it, or + ``None`` for a replay given no queue model (v4.1.1, schema 2). The + model is supplied back to :func:`restore` and held to the book. """ run: RunSnapshot @@ -193,6 +200,7 @@ class ReplaySnapshot: events_dropped: int = 0 schedule_digest: str | None = None algorithms: Mapping[str, Any] | None = None + queue: Mapping[str, Any] | None = None schema_version: int = REPLAY_SNAPSHOT_SCHEMA @@ -252,6 +260,7 @@ def capture(paused: PausedReplay) -> ReplaySnapshot: events_dropped=cursor.system_events.dropped, schedule_digest=paused.schedule.digest if paused.schedule else None, algorithms=None if paused.algorithms is None else capture_book(paused.algorithms), + queue=None if paused.queue is None else capture_queue(paused.queue), ) @@ -376,6 +385,7 @@ def restore( dataset: MarketDataset, schedule: RunSchedule = EMPTY_SCHEDULE, execution: ExecutionPlan | None = None, + queue: QueueModel | None = None, ) -> PausedReplay: """Rebuild the paused replay a snapshot was captured from. @@ -387,9 +397,11 @@ def restore( applied twice or lost. ``execution`` (v4.1.1, ledger FUT-009) is the plan a replay that worked its orders in children was given, held to the book the payload carries, whose every algorithm is rebuilt and checked against its - recorded identity. The run is rebuilt by - :func:`alphalab.runtime.run_snapshot.restore`, which refuses an object - configured otherwise than the run recorded (ledger PER-008). + recorded identity. ``queue`` (v4.1.1, ledger FUT-011) is the queue model a + replay was given, held to the type and configuration its book records. The + run is rebuilt by :func:`alphalab.runtime.run_snapshot.restore`, which + refuses an object configured otherwise than the run recorded (ledger + PER-008). Returns: The paused replay. Nothing is processed and no identifier is minted: @@ -402,8 +414,10 @@ def restore( any record changed (:func:`dataset_digest`); if ``schedule`` is not the one it was paused on; if ``execution`` is not the plan its book was produced under, or is missing, or is given for a replay - that had none; or if a live object is missing, of the wrong type, - or configured otherwise. + that had none; if ``queue`` is likewise not the queue model its + queue book was produced under, missing, or given for a replay that + had none; or if a live object is missing, of the wrong type, or + configured otherwise. """ _check_cursor(snapshot) @@ -441,6 +455,7 @@ def restore( book = book_from_primitives(snapshot.algorithms, execution) except (ExecutionPlanError, KeyError, TypeError, ValueError, ArithmeticError) as refused: raise _refuse(f"its algorithm book cannot be continued: {refused}") from refused + queued = _queue_book(snapshot, queue) records = dataset.records index = snapshot.current_index expected_clock = records[index - 1].timestamp if index else snapshot.session.start_time @@ -469,9 +484,31 @@ def restore( cursor_position=snapshot.cursor_position, schedule=schedule, algorithms=book, + queue=queued, ) +def _queue_book(snapshot: ReplaySnapshot, model: QueueModel | None) -> QueueBook | None: + """The paused replay's queue book, held to the model supplied -- or refused.""" + + if snapshot.queue is None: + if model is not None: + raise _refuse( + "it was paused with no queue model, and one was supplied; governing its " + "resting orders by a queue would change what the replay is." + ) + return None + if model is None: + raise _refuse( + "its resting orders were governed by a queue model, and none was supplied: " + "resuming without it would fill them as though nothing were ahead of them." + ) + try: + return queue_from_primitives(snapshot.queue, model) + except (QueueModelError, KeyError, TypeError, ValueError, ArithmeticError) as refused: + raise _refuse(f"its queue book cannot be continued: {refused}") from refused + + # --------------------------------------------------------------------------- # Decoding a JSON payload back into snapshot types # --------------------------------------------------------------------------- @@ -546,6 +583,12 @@ def _optional_book(value: Any) -> Mapping[str, Any] | None: return None if value is None else as_mapping(value, "algorithms") +def _optional_queue(value: Any) -> Mapping[str, Any] | None: + """The queue book's primitives, as they are: decoded by :func:`restore`, given its model.""" + + return None if value is None else as_mapping(value, "queue") + + def from_primitives(payload: Mapping[str, Any]) -> ReplaySnapshot: """Decode a JSON-decoded replay payload back into :class:`ReplaySnapshot`. @@ -577,5 +620,6 @@ def from_primitives(payload: Mapping[str, Any]) -> ReplaySnapshot: events_dropped=as_int(require(payload, "events_dropped"), "events_dropped"), schedule_digest=as_optional_str(require(payload, "schedule_digest"), "schedule_digest"), algorithms=_optional_book(require(payload, "algorithms")), + queue=_optional_queue(require(payload, "queue")), schema_version=REPLAY_SNAPSHOT_SCHEMA, ) diff --git a/alphalab/backtesting/state.py b/alphalab/backtesting/state.py index 57e90f42..84828961 100644 --- a/alphalab/backtesting/state.py +++ b/alphalab/backtesting/state.py @@ -10,7 +10,10 @@ FUT-009) returns an :class:`AlgorithmicResult`: the same projection, and beside it the plan's :class:`~alphalab.backtesting.algorithmic.AlgorithmBook` -- driver state the run does not hold, as a schedule's cursor is not, so it is no second -copy of anything, and the run's record and ``result_id`` are what they were. +copy of anything, and the run's record and ``result_id`` are what they were. A +run given a queue model (v4.1.1, ledger FUT-011) returns a :class:`QueuedResult` +on the same terms, carrying its +:class:`~alphalab.execution.queue.QueueBook`. """ from __future__ import annotations @@ -22,18 +25,19 @@ from alphalab.analytics.report import PerformanceReport from alphalab.core.fill import Fill as CoreFill from alphalab.core.trade import Trade as CoreTrade +from alphalab.execution.queue import QueueBook from alphalab.market.record import MarketRecord from alphalab.oms.order import Order as OMSOrder from alphalab.portfolio.fx import NO_RATES, FxRates from alphalab.portfolio.valuation import PortfolioValuation, PortfolioValuationSnapshot -from alphalab.runtime.assumptions import ExecutionAssumptions +from alphalab.runtime.assumptions import ExecutionAssumptions, execution_assumptions from alphalab.runtime.execution_pipeline import ExecutionPipelineState, UnpricedAsset from alphalab.runtime.run import RunConfig, RunState, RunStep, StrategyFailure if TYPE_CHECKING: from alphalab.backtesting.algorithmic import AlgorithmBook -__all__ = ["AlgorithmicResult", "BacktestResult", "ReplayResult"] +__all__ = ["AlgorithmicResult", "BacktestResult", "QueuedResult", "ReplayResult"] @dataclass(frozen=True, slots=True) @@ -206,6 +210,29 @@ class AlgorithmicResult(BacktestResult): algorithms: AlgorithmBook +@dataclass(frozen=True, slots=True) +class QueuedResult(BacktestResult): + """A finished run whose resting limit orders a queue model governed (v4.1.1, FUT-011). + + Everything :class:`BacktestResult` reads, through the run, and the queue + book the run does not hold. Its :attr:`execution_assumptions` name the + queue model, which is what a run's figures were measured under. + + Attributes: + queue: The queue model, and the position of every limit order still + resting when the data ended. + """ + + queue: QueueBook + + @property + def execution_assumptions(self) -> ExecutionAssumptions: + """How the run modelled execution, its queue model included.""" + + config = self.run.config + return execution_assumptions(config.pipeline, config.fill_policy, self.queue.model) + + @dataclass(frozen=True, slots=True) class ReplayResult: """A replay run: the backtest result plus the replay cursor it was driven by.""" @@ -232,6 +259,13 @@ def algorithms(self) -> AlgorithmBook | None: backtest = self.backtest return backtest.algorithms if isinstance(backtest, AlgorithmicResult) else None + @property + def queue(self) -> QueueBook | None: + """The queue book, for a replay given a queue model (v4.1.1); ``None`` otherwise.""" + + backtest = self.backtest + return backtest.queue if isinstance(backtest, QueuedResult) else None + @property def unpriced_assets(self) -> tuple[UnpricedAsset, ...]: """What the replayed run declined to trade, read from the run it wraps.""" diff --git a/alphalab/execution/__init__.py b/alphalab/execution/__init__.py index a6aed63e..c7e7d10b 100644 --- a/alphalab/execution/__init__.py +++ b/alphalab/execution/__init__.py @@ -132,6 +132,14 @@ shortfall_against_model, venue_quality, ) +from alphalab.execution.queue import ( + PriceTimeQueue, + QueueBook, + QueueModel, + QueueObservation, + QueuePosition, + QueueTurn, +) from alphalab.execution.report import ExecutionReport from alphalab.execution.routing import ( MAX_SPLIT_FIXED_CHARGE_VENUES, @@ -245,8 +253,14 @@ "PerTradeFee", "PercentageCommission", "PercentageSlippage", + "PriceTimeQueue", "ProportionalFee", "ProportionalTax", + "QueueBook", + "QueueModel", + "QueueObservation", + "QueuePosition", + "QueueTurn", "QuotedHalfSpread", "ReferencePrice", "RejectionRate", diff --git a/alphalab/execution/queue.py b/alphalab/execution/queue.py new file mode 100644 index 00000000..0186ce20 --- /dev/null +++ b/alphalab/execution/queue.py @@ -0,0 +1,308 @@ +"""Queue models: whether a resting limit order has reached the front of its price level. + +Without one, a simulated resting limit order fills when the market reaches its +limit -- a bar whose low touches a buy limit fills it -- as if nothing had been +resting at that price before it. On a venue it would have joined a queue, and +traded only once what was ahead of it had traded or left. A backtest that +ignores the queue is optimistic about every passive fill, and most of all about +a market maker's (ledger FUT-011). + +A :class:`QueueModel` is that question, and nothing else. It is given what one +market event showed, projected into a :class:`QueueObservation` -- the quote, +the trade print, the bar's range, each when the event carried it -- and keeps, +per resting order, a :class:`QueuePosition`: how much is ahead of it at its +limit. It answers with a :class:`QueueTurn`: the order's new position, and how +much of it may fill at this event. How much *does* fill is still the run's fill +policy, and the price and costs are still the simulator's -- the queue model +only bounds which passive fills may happen, so a run that declares one fills +through exactly the path a run that does not fills through. + +Like a fill policy it depends on nothing but :mod:`alphalab.core` and never +touches state: the per-order positions are a :class:`QueueBook`, a value the +driver threads through the run (see +:class:`~alphalab.backtesting.state.QueuedResult`). + +What :class:`PriceTimeQueue` assumes, and what it does not +------------------------------------------------------------- +It models one venue's **price-time (FIFO) priority over displayed liquidity**, +which is a common rule and not a universal one: a pro-rata or size-priority +book allocates differently and is another model implementing the protocol, not +a parameter of this one. Hidden and iceberg liquidity, self-trade prevention, +and orders that keep their priority when amended are not modelled -- the +simulated path has no amend; a replacement is a new order and joins at the +back. It reads only the event at hand, so it never knows more than the venue's +public feed had shown by that instant. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Protocol + +from alphalab.common.persistent_map import PersistentMap +from alphalab.core.enums import Side + +__all__ = [ + "PriceTimeQueue", + "QueueBook", + "QueueModel", + "QueueObservation", + "QueuePosition", + "QueueTurn", +] + +_ZERO = Decimal("0") + + +@dataclass(frozen=True, slots=True) +class QueueObservation: + """What one market event showed that bears on a resting order's queue. + + Each group is ``None`` when the event did not carry it -- a bar carries a + range and no quote, a trade print a price and no quote -- and a model reads + an absent group as "not observed", never as zero. + + Attributes: + timestamp: The event's instant. + bid, bid_size, ask, ask_size: The top of the book a quote showed. + trade_price, trade_quantity: The print a trade or tick reported. + aggressor: Which side crossed the spread to make the print, when the + venue reported it -- ``BUY`` for a buyer taking the offer. A print's + side is never inferred. + low, high: The range a bar traded through. + """ + + timestamp: float + bid: Decimal | None = None + bid_size: Decimal | None = None + ask: Decimal | None = None + ask_size: Decimal | None = None + trade_price: Decimal | None = None + trade_quantity: Decimal | None = None + aggressor: Side | None = None + low: Decimal | None = None + high: Decimal | None = None + + +@dataclass(frozen=True, slots=True) +class QueuePosition: + """Where one resting order stands at its limit. + + Attributes: + limit_price: The price level the order rests at. + ahead: The quantity ahead of it at that level, or ``None`` when no + event has shown it -- the order joined a level the feed did not + size, a bar's or one behind the best price. An unknown queue is + treated as long: the order fills only when the market trades + through its limit. + """ + + limit_price: Decimal + ahead: Decimal | None + + +@dataclass(frozen=True, slots=True) +class QueueTurn: + """A model's answer for one resting order at one event. + + Attributes: + position: The order's position after the event. + eligible: How much of the order may fill at this event: ``None`` when + nothing is ahead of it and the event reached its limit, so the fill + policy alone decides; a quantity when only that much traded beyond + the queue ahead of it; zero when it may not fill. + """ + + position: QueuePosition + eligible: Decimal | None + + @property + def may_fill(self) -> bool: + """Whether any of the order may fill at this event.""" + + return self.eligible is None or self.eligible > _ZERO + + +class QueueModel(Protocol): + """Keeps a resting limit order's place in its price level's queue.""" + + def join( + self, side: Side, limit_price: Decimal, observation: QueueObservation + ) -> QueuePosition: + """Where an order resting at ``limit_price`` stands, from the event it joined at.""" + ... + + def advance( + self, position: QueuePosition, side: Side, observation: QueueObservation + ) -> QueueTurn: + """The order's position after ``observation``, and what of it may fill there.""" + ... + + +@dataclass(frozen=True, slots=True) +class PriceTimeQueue: + """Price-time priority over displayed liquidity, read from the top of the book and prints. + + **Joining.** An order at a price better than the best on its side has + nothing ahead of it. At the best price, everything the quote showed there + is ahead. Behind the best price, or on an event with no quote, the queue is + unknown (:attr:`QueuePosition.ahead` is ``None``). + + **Advancing**, at each later event of the order's asset: + + * a quote showing the order's level as the best on its side bounds the + queue by the size shown there -- what is shown includes everyone still + ahead, and may include orders that arrived behind, so the bound is + conservative; a best price *worse* than the level means nothing is + displayed there any more, and the queue ahead has left, by trading or + cancelling; + * a print **at** the limit, by a seller hitting a buy level (a buyer + lifting a sell level), consumes the queue first, and what trades beyond + it may fill the order. A print whose side the venue did not report counts + only with ``count_unsigned_trades``, since it may have traded on the + other side of the book; + * the market trading **through** the limit -- a print, a quote or a bar's + range strictly beyond it -- cleared the level, and the order may fill; + * the market **touching** the limit without trading through it fills the + order only when nothing is ahead of it. + + A quote never adds to the queue ahead and nothing here is drawn at random, + so a run is reproducible and every assumption is on the conservative side + of what the feed showed. + + Attributes: + count_unsigned_trades: Whether a print at the limit with no reported + aggressor consumes the queue. Off by default: a feed that does not + sign its prints then fills a resting order only on a trade-through + or once its queue is displayed as gone. + """ + + count_unsigned_trades: bool = False + + def join( + self, side: Side, limit_price: Decimal, observation: QueueObservation + ) -> QueuePosition: + best, size = _same_side(side, observation) + if best is None: + return QueuePosition(limit_price, None) + if _better(side, limit_price, best): + return QueuePosition(limit_price, _ZERO) + if limit_price == best: + return QueuePosition(limit_price, size) + return QueuePosition(limit_price, None) + + def advance( + self, position: QueuePosition, side: Side, observation: QueueObservation + ) -> QueueTurn: + limit = position.limit_price + ahead = position.ahead + + best, size = _same_side(side, observation) + if best is not None: + if _better(side, limit, best): + ahead = _ZERO + elif best == limit and size is not None: + ahead = size if ahead is None else min(ahead, size) + + through = False + touched = False + beyond: Decimal | None = None + + opposite = observation.ask if side is Side.BUY else observation.bid + if opposite is not None: + through = through or _better(side, limit, opposite) + touched = touched or opposite == limit + + price = observation.trade_price + if price is not None: + if _better(side, limit, price): + through = True + elif price == limit and self._consumes(side, observation.aggressor): + printed = observation.trade_quantity + if ahead is not None and printed is not None: + if printed > ahead: + beyond = printed - ahead + ahead = _ZERO + else: + ahead = ahead - printed + beyond = _ZERO + + extreme = observation.low if side is Side.BUY else observation.high + if extreme is not None: + through = through or _better(side, limit, extreme) + touched = touched or extreme == limit + + if through: + return QueueTurn(QueuePosition(limit, _ZERO), None) + if beyond is not None and beyond > _ZERO: + return QueueTurn(QueuePosition(limit, ahead), beyond) + if touched and ahead == _ZERO: + return QueueTurn(QueuePosition(limit, ahead), None) + return QueueTurn(QueuePosition(limit, ahead), _ZERO) + + def _consumes(self, side: Side, aggressor: Side | None) -> bool: + """Whether a print at the limit traded against the order's side of the book.""" + + if aggressor is None: + return self.count_unsigned_trades + return aggressor is not side + + +def _same_side(side: Side, observation: QueueObservation) -> tuple[Decimal | None, Decimal | None]: + if side is Side.BUY: + return observation.bid, observation.bid_size + return observation.ask, observation.ask_size + + +def _better(side: Side, limit: Decimal, price: Decimal) -> bool: + """Whether ``limit`` is strictly better for its order than ``price`` -- the market beyond it.""" + + return limit > price if side is Side.BUY else limit < price + + +@dataclass(frozen=True, slots=True) +class QueueBook: + """A run's queue model and the position of each resting order it governs. + + A value: every method returns a new book. Keyed by the order's identifier; + an order is in the book from the event it joined at until it is no longer + working. + + Attributes: + model: The queue model the run declared. + positions: Each governed order's position, by order id. + """ + + model: QueueModel + positions: PersistentMap[str, QueuePosition] = field(default_factory=PersistentMap) + + def position_of(self, order_id: str) -> QueuePosition | None: + """The order's position, or ``None`` if it has not joined.""" + + return self.positions.get(order_id) + + def placed(self, order_id: str, position: QueuePosition) -> QueueBook: + """This book with ``order_id`` at ``position``.""" + + return QueueBook(self.model, self.positions.set(order_id, position)) + + def without(self, order_id: str) -> QueueBook: + """This book without ``order_id`` -- an order that is no longer working.""" + + if order_id not in self.positions: + return self + return QueueBook(self.model, self.positions.delete(order_id)) + + def keeping(self, order_ids: Iterable[str]) -> QueueBook: + """This book holding only the orders named -- the ones still working.""" + + keep = set(order_ids) + stale = [order_id for order_id in self.positions if order_id not in keep] + if not stale: + return self + positions = self.positions + for order_id in stale: + positions = positions.delete(order_id) + return QueueBook(self.model, positions) diff --git a/alphalab/lifecycle/reproducibility.py b/alphalab/lifecycle/reproducibility.py index 9d1bd518..8bdbf7ea 100644 --- a/alphalab/lifecycle/reproducibility.py +++ b/alphalab/lifecycle/reproducibility.py @@ -424,6 +424,11 @@ class ReproducibilityManifest: of the plan whose algorithms worked the run's orders in children (v4.1.1, ledger FUT-009), or ``None`` for a run that filled them whole. Rendered into the identity only when present. + queue_model: The queue model that governed the run's resting limit + orders, described (v4.1.1, ledger FUT-011), or ``None`` for a run + that declared none. Rendered into the identity only when present: + the run's configuration does not carry it, so without it two runs + whose passive fills were decided differently would share one. """ manifest_id: str @@ -441,6 +446,7 @@ class ReproducibilityManifest: schedule_digest: str | None = None dataset_parts: tuple[tuple[str, str], ...] = () execution_plan_id: str | None = None + queue_model: str | None = None @property def evidence_kind(self) -> str: @@ -498,6 +504,7 @@ def canonical_manifest_key( schedule_digest: str | None = None, dataset_parts: tuple[tuple[str, str], ...] = (), execution_plan_id: str | None = None, + queue_model: str | None = None, ) -> str: """Render the canonical key a manifest's identity is derived from. @@ -534,6 +541,8 @@ def canonical_manifest_key( ) if execution_plan_id is not None: lines.append(f"inputs.execution_plan={execution_plan_id!r}") + if queue_model is not None: + lines.append(f"inputs.queue_model={queue_model!r}") return "\n".join(lines) @@ -551,6 +560,7 @@ def derive_manifest_id( schedule_digest: str | None = None, dataset_parts: tuple[tuple[str, str], ...] = (), execution_plan_id: str | None = None, + queue_model: str | None = None, ) -> str: """SHA-256 over :func:`canonical_manifest_key`. Derived, never minted.""" @@ -569,6 +579,7 @@ def derive_manifest_id( schedule_digest, dataset_parts, execution_plan_id, + queue_model, ) ) @@ -587,6 +598,7 @@ def _manifest( schedule_digest: str | None = None, dataset_parts: tuple[tuple[str, str], ...] = (), execution_plan_id: str | None = None, + queue_model: str | None = None, ) -> ReproducibilityManifest: configuration_id = _sha256(configuration) return ReproducibilityManifest( @@ -604,6 +616,7 @@ def _manifest( schedule_digest, dataset_parts, execution_plan_id, + queue_model, ), kind=kind, result_id=result_id, @@ -619,6 +632,7 @@ def _manifest( schedule_digest=schedule_digest, dataset_parts=dataset_parts, execution_plan_id=execution_plan_id, + queue_model=queue_model, ) @@ -788,6 +802,8 @@ def manifest_for_run( schedule_digest=None if schedule is None or not schedule else schedule.digest, dataset_parts=_parts_of(provenance), execution_plan_id=None if execution is None else execution.plan_id, + # Read off the result, which names the model it ran under (FUT-011). + queue_model=result.execution_assumptions.queue_model, ) @@ -956,6 +972,7 @@ def verify_manifest(manifest: ReproducibilityManifest) -> bool: manifest.schedule_digest, manifest.dataset_parts, manifest.execution_plan_id, + manifest.queue_model, ) @@ -1128,6 +1145,16 @@ def external_requirements(manifest: ReproducibilityManifest) -> tuple[ExternalRe recreatable=True, ) ) + if manifest.queue_model is not None: + requirements.append( + ExternalRequirement( + ExternalInput.AUXILIARY_DATA, + f"the queue model {manifest.queue_model} that decided which resting " + "limit orders reached the front of their price level, supplied exactly " + "as it was", + recreatable=True, + ) + ) if manifest.mode is ExecutionMode.LIVE: requirements.append( ExternalRequirement( @@ -1257,6 +1284,7 @@ def _input_differences( ("schedule", repr(original.schedule_digest), repr(rerun.schedule_digest)), ("dataset parts", repr(original.dataset_parts), repr(rerun.dataset_parts)), ("execution plan", repr(original.execution_plan_id), repr(rerun.execution_plan_id)), + ("queue model", repr(original.queue_model), repr(rerun.queue_model)), ): if before != after: differences.append(f"{label}: {before} -> {after}") diff --git a/alphalab/runtime/assumptions.py b/alphalab/runtime/assumptions.py index 8dcc83b5..0818856e 100644 --- a/alphalab/runtime/assumptions.py +++ b/alphalab/runtime/assumptions.py @@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, Any from alphalab.execution.policy import FillPolicy, FillTiming, ImmediateFill +from alphalab.execution.queue import QueueModel if TYPE_CHECKING: from alphalab.runtime.execution_pipeline import ExecutionPipelineConfig, ExecutionRouting @@ -106,6 +107,10 @@ class ExecutionAssumptions: (:attr:`~alphalab.execution.simulator.ExecutionSimulator.is_frictionless`). unlimited_liquidity: Whether every order filled in full (:class:`~alphalab.execution.policy.ImmediateFill`). + queue_model: The queue model that decided which resting limit orders + had reached the front of their price level, described, or ``None`` + when the run declared none (v4.1.1, ledger FUT-011) -- see + :attr:`passive_fills`. """ routing: ExecutionRouting @@ -115,6 +120,7 @@ class ExecutionAssumptions: latency: str frictionless: bool unlimited_liquidity: bool + queue_model: str | None = None @property def simulated(self) -> bool: @@ -139,6 +145,29 @@ def optimistic(self) -> tuple[str, ...]: found.append("liquidity is unlimited: every order fills in full (ImmediateFill)") return tuple(found) + @property + def passive_fills(self) -> str: + """How a resting limit order's fills were decided, in a sentence (FUT-011). + + Not one of :attr:`optimistic`, which names what makes *every* simulated + fill optimistic: this bears only on orders that rested at a limit, and + a run that placed none is not made optimistic by it. Read it beside the + figures of any run that did -- a market maker's, or one working passive + orders. + """ + + if not self.simulated: + return "resting orders were filled by their venue, not simulated" + if self.queue_model is None: + return ( + "a resting limit order filled when the market reached its limit, with no " + "queue ahead of it -- optimistic for passive orders (no queue model)" + ) + return ( + "a resting limit order filled only once its queue model put it at the front " + f"of its price level: {self.queue_model}" + ) + def settings(self) -> Mapping[str, str]: """The assumptions as research settings, keyed ``execution.*``. @@ -147,21 +176,26 @@ def settings(self) -> Mapping[str, str]: its evidence was measured under, not only the code. """ - return MappingProxyType( - { - "execution.routing": self.routing.name, - "execution.fill_timing": self.fill_timing.value, - "execution.fill_policy": self.fill_policy, - "execution.costs": self.costs, - "execution.latency": self.latency, - } - ) + settings = { + "execution.routing": self.routing.name, + "execution.fill_timing": self.fill_timing.value, + "execution.fill_policy": self.fill_policy, + "execution.costs": self.costs, + "execution.latency": self.latency, + } + if self.queue_model is not None: + # Present only when declared, so a run without one is identified + # exactly as it was before queue models existed. + settings["execution.queue_model"] = self.queue_model + return MappingProxyType(settings) def execution_assumptions( - pipeline: ExecutionPipelineConfig, fill_policy: FillPolicy + pipeline: ExecutionPipelineConfig, + fill_policy: FillPolicy, + queue_model: QueueModel | None = None, ) -> ExecutionAssumptions: - """What a pipeline configured this way, with this fill policy, assumes.""" + """What a pipeline configured this way, with this fill policy and queue model, assumes.""" simulator = pipeline.simulator costs = describe(simulator.costs) @@ -178,4 +212,5 @@ def execution_assumptions( latency=describe(simulator.latency_model), frictionless=simulator.is_frictionless, unlimited_liquidity=isinstance(fill_policy, ImmediateFill), + queue_model=None if queue_model is None else describe(queue_model), ) diff --git a/alphalab/runtime/execution_pipeline.py b/alphalab/runtime/execution_pipeline.py index b03d7ba3..39944877 100644 --- a/alphalab/runtime/execution_pipeline.py +++ b/alphalab/runtime/execution_pipeline.py @@ -39,6 +39,7 @@ from alphalab.core.order_request import OrderRequest from alphalab.core.trade import Trade as CoreTrade from alphalab.data.calendar import MAX_SESSION_SEARCH_DAYS +from alphalab.data.feed import TradeAggressor from alphalab.execution.algorithms import ChildOrder from alphalab.execution.algorithms import _apportion as _largest_remainder from alphalab.execution.engine import ExecutionEngine @@ -50,6 +51,7 @@ LiquidityContext, StaticFill, ) +from alphalab.execution.queue import QueueBook, QueueObservation, QueuePosition, QueueTurn from alphalab.execution.report import ExecutionReport from alphalab.execution.simulator import ExecutionSimulator from alphalab.execution.state import ExecutionState @@ -558,6 +560,10 @@ class ExecutionPipelineResult: #: keeps working. Derived and never persisted: no snapshot carries an #: ``ExecutionPipelineResult``. See :class:`SettlementRefusal` and ADR-0028. settlement_refusals: tuple[SettlementRefusal, ...] = field(default_factory=tuple) + #: The run's queue book after the step, when one was given (v4.1.1, ledger + #: FUT-011): each resting limit order's place at its price level. ``None`` + #: for a step taken without one. + queue: QueueBook | None = None def _populate_context( @@ -1027,6 +1033,8 @@ def process_record( context_factory: ContextFactory, fill_policy: FillPolicy | None = None, rates: FxRates = NO_RATES, + *, + queue: QueueBook | None = None, ) -> ExecutionPipelineResult: """Move one market record through the whole execution path. @@ -1046,6 +1054,7 @@ def process_record( context_factory, fill_policy=fill_policy, rates=rates, + queue=queue, ) @staticmethod @@ -1058,6 +1067,8 @@ def process_market_event( fill_quantity: Decimal | None = None, fill_policy: FillPolicy | None = None, rates: FxRates = NO_RATES, + *, + queue: QueueBook | None = None, ) -> ExecutionPipelineResult: """Route one market event through strategy, order, execution, and portfolio. @@ -1093,6 +1104,16 @@ def process_market_event( would move ``PIPELINE_SNAPSHOT_SCHEMA``. It is not on ``ExecutionPipelineState`` either, which ADR-0030 fixes at sixteen fields because every one of them is paid eleven times per record. + + ``queue`` (v4.1.1, ledger FUT-011) is the run's + :class:`~alphalab.execution.queue.QueueBook`, a parameter for the same + reasons. With one, a simulated resting limit order working in the + event's asset -- a limit order, or a triggered stop-limit, that is not + immediate and not an auction order -- fills at this event only as far as + its queue model allows, the fill policy deciding within that; an order + the step leaves resting joins its queue at this event; and the book that + comes back on the result holds only orders still working. Without one + nothing changes. """ refusal = price_refusal(state, _event_payload(event)) @@ -1135,7 +1156,14 @@ def process_market_event( and state.config.routing is ExecutionRouting.SIMULATED and current.oms.working_orders_for(update[0]) ): - current, earlier = _fill_working_orders(current, event, update[0], policy, rates) + current, earlier, queue = _fill_working_orders( + current, event, update[0], policy, rates, queue + ) + # Every order still working in the asset now has had its turn; what the + # step places from here on is appended after them (FUT-011). + resting_before = ( + _working_count(current, update[0]) if queue is not None and update is not None else 0 + ) # Assembled from the marked state above, after marking, after the risk # resync and after any working order filled, and before dispatch. The @@ -1178,6 +1206,8 @@ def process_market_event( contributions_before=contributions_before, records_before=records_before, ) + if queue is not None: + queue = _queue_after_step(queue, current, event, update, resting_before) return _step_result( current, event, @@ -1185,6 +1215,7 @@ def process_market_event( (*requests, *feedback_requests), routed.then(feedback), rates, + queue, ) @staticmethod @@ -2300,6 +2331,7 @@ def _step_result( requests: tuple[OrderRequest, ...], routed: _Routed, rates: FxRates, + queue: QueueBook | None = None, ) -> ExecutionPipelineResult: """Close the step: value the book once, record the snapshot, and report.""" @@ -2335,6 +2367,7 @@ def _step_result( routed.unpriced, valuation, routed.refusals, + queue, ) @@ -3501,6 +3534,7 @@ def _work_resting( price: Decimal, passive: bool, one_shot: bool, + cap: Decimal | None = None, ) -> tuple[ ExecutionPipelineState, tuple[ExecutionReport, ...], @@ -3512,10 +3546,13 @@ def _work_resting( What does not fill goes on resting, unless the order is ``one_shot`` -- an immediate-or-cancel, fill-or-kill or auction order -- when it is cancelled. A decision to fill nothing is not a refusal of the order: a resting order - that found no liquidity this event rests. + that found no liquidity this event rests. ``cap`` is what a queue model + allows to fill, when less than the queue cleared (FUT-011). """ decision = _decide_fill(policy, order, event, price, _whole_units(state)) + if cap is not None: + decision = _within_queue(decision, order, cap, _whole_units(state)) if _kills(order, decision): return _end_unfilled(state, order, event.timestamp), (), (), () if decision.status is FillStatus.NO_FILL: @@ -3542,6 +3579,7 @@ def _work_order( rates: FxRates, *, arriving: bool, + queue_turn: QueueTurn | None = None, ) -> tuple[ ExecutionPipelineState, tuple[ExecutionReport, ...], @@ -3570,7 +3608,9 @@ def _work_order( and fill at their limit -- or at a bar's better open -- when the market trades through it, as a maker: no spread, slippage or impact. What does not fill rests on; an immediate-or-cancel or fill-or-kill order is - cancelled after its first turn instead. + cancelled after its first turn instead. Under a queue model + (``queue_turn``, FUT-011) a resting turn fills only what the model says + has reached the front of the order's price level. """ timestamp = event.timestamp @@ -3618,11 +3658,21 @@ def _work_order( return current, (), (), () resting_price = _resting_fill_price(order, event) + if queue_turn is not None and not queue_turn.may_fill: + resting_price = None if resting_price is None: current = _end_unfilled(current, order, timestamp) if one_shot else current return current, (), (), () return _work_resting( - current, order, event, policy, rates, price=resting_price, passive=True, one_shot=one_shot + current, + order, + event, + policy, + rates, + price=resting_price, + passive=True, + one_shot=one_shot, + cap=None if queue_turn is None else queue_turn.eligible, ) @@ -3632,31 +3682,178 @@ def _fill_working_orders( asset_id: str, policy: FillPolicy, rates: FxRates, -) -> tuple[ExecutionPipelineState, _Routed]: + queue: QueueBook | None = None, +) -> tuple[ExecutionPipelineState, _Routed, QueueBook | None]: """Work the simulated orders left working in ``asset_id``, at ``event``. In the order they were placed, each gets its turn at what this event showed -- see :func:`_work_order`: a market order its one attempt, a resting order - the fill the event's prices give it, or nothing. + the fill the event's prices give it, or nothing. Under a queue model each + resting limit order's position is advanced by the event first, from where + it stood -- or, for an order that has not joined, from an unknown queue -- + and its turn fills only what that allows (FUT-011). """ current = state + observation = None if queue is None else _queue_observation(event) orders: list[OMSOrder] = [] reports: list[ExecutionReport] = [] fills: list[CoreFill] = [] trades: list[CoreTrade] = [] for order_id in state.oms.working_orders_for(asset_id): order = current.oms.orders.find(order_id) + turn = None + key = "" + if queue is not None and observation is not None: + key = str(order_id.value) + if _queued(order): + held = queue.position_of(key) + limit = order.limit_price + assert limit is not None # a queued order is a limit order + position = held + if position is None or position.limit_price != limit: + position = QueuePosition(limit, None) + turn = queue.model.advance(position, order.side, observation) + if turn.position != held: + queue = queue.placed(key, turn.position) current, new_reports, new_fills, new_trades = _work_order( - current, order, event, policy, rates, arriving=False + current, order, event, policy, rates, arriving=False, queue_turn=turn ) - orders.append(current.oms.orders.find(order_id)) + after = current.oms.orders.find(order_id) + if queue is not None and observation is not None: + if not after.is_open: + # Filled, expired or withdrawn: it leaves the queue with the book. + queue = queue.without(key) + elif turn is None and _queued(after): + # A stop-limit triggered at this event rests from here: it joins now. + limit = after.limit_price + assert limit is not None + queue = queue.placed(key, queue.model.join(after.side, limit, observation)) + orders.append(after) reports.extend(new_reports) fills.extend(new_fills) trades.extend(new_trades) - return current, _Routed( + routed = _Routed( orders=tuple(orders), reports=tuple(reports), fills=tuple(fills), trades=tuple(trades) ) + return current, routed, queue + + +def _queued(order: OMSOrder) -> bool: + """Whether a queue model governs ``order``: a limit order resting in continuous trading. + + A limit order, or a stop-limit once triggered, that is neither immediate -- + it never rests -- nor an auction order, which an auction fills at one price + rather than a queue (FUT-011). + """ + + if order.time_in_force in _NOT_QUEUED: + return False + if order.order_type is OrderType.LIMIT: + return True + return order.order_type is OrderType.STOP_LIMIT and order.triggered_at is not None + + +#: Times in force whose orders never rest in a queue: they end at their first +#: attempt (``IOC``, ``FOK``) or are filled by an auction (``OPG``, ``CLS``) -- +#: what :attr:`OrderTerms.is_immediate` and :attr:`OrderTerms.is_auction` name, +#: read off the order without building its terms on every turn. +_NOT_QUEUED: Final = frozenset({TimeInForce.IOC, TimeInForce.FOK, TimeInForce.OPG, TimeInForce.CLS}) + + +_AGGRESSOR: Final = {TradeAggressor.BUYER: CoreSide.BUY, TradeAggressor.SELLER: CoreSide.SELL} + + +def _queue_observation(event: MarketEvent) -> QueueObservation: + """What ``event`` showed that a queue model reads -- see :class:`QueueObservation`.""" + + if isinstance(event, QuoteReceived): + quote = event.quote + return QueueObservation( + event.timestamp, + bid=quote.bid, + bid_size=quote.bid_size, + ask=quote.ask, + ask_size=quote.ask_size, + ) + if isinstance(event, TickReceived | TradeReceived): + tick = event.tick + return QueueObservation( + event.timestamp, + trade_price=tick.price, + trade_quantity=tick.quantity, + aggressor=None if tick.aggressor is None else _AGGRESSOR[tick.aggressor], + ) + if isinstance(event, BarClosed): + return QueueObservation(event.timestamp, low=event.bar.low, high=event.bar.high) + return QueueObservation(event.timestamp) + + +def _queue_after_step( + queue: QueueBook, + state: ExecutionPipelineState, + event: MarketEvent, + update: tuple[str, Decimal] | None, + resting_before: int, +) -> QueueBook: + """``queue`` after a step: the orders it left resting in the event's asset joined. + + Each joins at this event -- the one it was placed at -- from what the + event showed, which the strategy had seen before placing it. They are the + asset's working orders after the ``resting_before`` that were working when + the step began placing orders: the working orders of an asset are kept in + the order they were placed. An order placed outside a market event of its + asset -- by a timer, a slice or an observation -- has no event of its own + to join at, and starts its first turn from an unknown queue instead. + + Orders that end at their turn leave the book there + (:func:`_fill_working_orders`); one ended otherwise -- a settlement, a + split, an outcome a caller reports -- is never worked again, and leaves it + when the driver next keeps only working orders (at a pause, and at the end). + """ + + if state.config.routing is not ExecutionRouting.SIMULATED or update is None: + return queue + if _working_count(state, update[0]) == resting_before: + return queue # the step placed nothing that rests in this asset + placed = state.oms.working_orders_for(update[0])[resting_before:] + observation: QueueObservation | None = None + for order_id in placed: + key = str(order_id.value) + if queue.position_of(key) is not None: + continue + order = state.oms.orders.find(order_id) + if not _queued(order): + continue + limit = order.limit_price + assert limit is not None # a queued order is a limit order + if observation is None: + observation = _queue_observation(event) + queue = queue.placed(key, queue.model.join(order.side, limit, observation)) + return queue + + +def _working_count(state: ExecutionPipelineState, asset_id: str) -> int: + """How many orders are working in ``asset_id``, without listing them.""" + + working = state.oms.working_by_asset.get(asset_id) + return 0 if working is None else len(working) + + +def _within_queue( + decision: FillDecision, order: OMSOrder, cap: Decimal, whole_units: bool +) -> FillDecision: + """``decision`` held to what a queue model allows, in whole units when the run trades them.""" + + if decision.status not in (FillStatus.FULL_FILL, FillStatus.PARTIAL_FILL): + return decision + quantity = order.remaining_quantity if decision.quantity is None else decision.quantity + if quantity <= cap: + return decision + allowed = cap.to_integral_value(rounding=decimal.ROUND_DOWN) if whole_units else cap + if allowed <= Decimal("0"): + return FillDecision(FillStatus.NO_FILL, None) + return FillDecision(FillStatus.PARTIAL_FILL, allowed) def _classify_unpriced(state: ExecutionPipelineState, asset_id: str) -> tuple[UnpricedReason, str]: diff --git a/alphalab/runtime/run.py b/alphalab/runtime/run.py index 8c51288f..28bd1455 100644 --- a/alphalab/runtime/run.py +++ b/alphalab/runtime/run.py @@ -63,6 +63,7 @@ from alphalab.common.ids import IdStreamPosition, id_source_for, require_seed, use_id_source from alphalab.core.fill import Fill as CoreFill from alphalab.execution.policy import FillPolicy, ImmediateFill +from alphalab.execution.queue import QueueBook from alphalab.execution.report import ExecutionReport from alphalab.market.exceptions import MarketValidationError from alphalab.market.normalization import is_stale @@ -480,6 +481,8 @@ def advance( context_factory: ContextFactory, now: float | None = None, rates: FxRates = NO_RATES, + *, + queue: QueueBook | None = None, ) -> tuple[RunState, ExecutionPipelineResult | None]: """Move one record through the execution path, and record what it did. @@ -503,6 +506,14 @@ def advance( under which no record is ever stale -- the right answer for a historical run. A live or paper run passes its real clock. + ``queue`` (v4.1.1, ledger FUT-011) is the run's queue book, for a + simulated run whose resting limit orders a queue model governs; the + book after the step is :attr:`ExecutionPipelineResult.queue + `, + and a skipped record leaves it as it was. It is the driver's to carry, + as an execution plan's book is, so a run without one records exactly + what it did before. + Returns the next state and the pipeline result, or ``None`` when the record was skipped -- because it was stale, or because its timestamp went backwards and the run tolerates that. @@ -513,8 +524,16 @@ def advance( the portfolio at a price the market has already moved past, and the market engine's ``latest_*`` index would silently take the older quote as current. See ADR-0014. + RuntimeValidationError: If a queue book is given to a run that does + not simulate its orders: a venue, or an execution plan's + children, are filled elsewhere, and no queue governs them. """ + if queue is not None and state.config.pipeline.routing is not ExecutionRouting.SIMULATED: + raise RuntimeValidationError( + f"A queue model governs simulated resting orders; this run routes " + f"{state.config.pipeline.routing.name}, so none would be governed by it." + ) clock = record.timestamp if now is None else now limit = state.config.max_market_data_age_seconds if limit is not None and is_stale(record.timestamp, clock, limit): @@ -542,7 +561,7 @@ def advance( ) result = ExecutionPipeline.process_record( - state.pipeline, record, context_factory, state.config.fill_policy, rates + state.pipeline, record, context_factory, state.config.fill_policy, rates, queue=queue ) step = RunStep( index=state.processed, diff --git a/docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md b/docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md index 2740425b..d97d39bd 100644 --- a/docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md +++ b/docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md @@ -8,7 +8,8 @@ information on every driver (decisions 1-6), then the continuation that closed the engine-owned gaps the audit found (decisions 7-12), then the FUT-009 milestone, execution algorithms on the historical drivers (decision 13), then the FUT-013 milestone, a derivative's lifecycle declared and carried out -(decision 14). The release number is the +(decision 14), then the FUT-011 milestone, a queue model for resting limit +orders (decision 15). The release number is the maintainer's decision: the candidate adds public names, keyword-only parameters, an enum member and two schema steps (instrument 3 to 4, replay 1 to 2, each reading every earlier payload), which `docs/api/PUBLIC_API.md`'s policy and @@ -194,6 +195,28 @@ declared rule exercises longs and assigns shorts. An index is a new `AssetType`, a reference value cash options settle against, never traded. What cannot be represented or covered is refused before anything moves. +**15. A queue model decides which resting limit orders reached the front +(FUT-011).** A `QueueModel` (`alphalab.execution.queue`) given to either +historical driver, or the facade, as `queue=` keeps each resting limit order's +`QueuePosition` -- its limit and the quantity ahead of it, or unknown -- and at +each later event of its asset, before the strategy decides there, advances it +from what that event alone showed and says what of the order may fill. The run's +fill policy decides within that; the simulator prices the fill as a maker; the +OMS, portfolio, reservation and attribution settle it as every fill. The +position book is a parameter of the step, as the FX table is, and driver state, +as an execution plan's book is: a field on the pipeline state, the run state or +the run configuration would move every run's `result_id`, including runs that +declare no model, which this release does not do. So the result is a +`QueuedResult` naming the model in its execution assumptions, the manifest +records it beside the configuration, and a paused replay carries the book. +`PriceTimeQueue` is the one model shipped: price-time priority over displayed +liquidity, every rule conservative with respect to what the feed showed -- a +displayed size bounds the queue and never adds to it, an unsigned print counts +only when declared to, a touch fills only an order at the front. A venue that +allocates pro rata is another model of the protocol, not a parameter of this +one; refused beside an execution plan, whose children it would not see, and on +a run that routes to a venue. + --- # Consequences @@ -227,11 +250,15 @@ cannot be represented or covered is refused before anything moves. baskets, cash in lieu, quanto and Bermudan contracts are refused. - **A delivery the book cannot cover stops the run**: no margin call, collateral or forced liquidation is modelled. +- **The queue model reads the displayed top of the book**: depth beyond it, + hidden and iceberg liquidity, self-trade prevention and keep-priority amends + are not modelled; the run's own resting orders are not in the feed, so each is + queued against the market's queue independently; no fill probability is drawn. Each is stated in the ledger entry it belongs to (FUT-010, FUT-006, FUT-005, -FUT-009, FUT-014, FUT-015, FUT-016, FUT-017). +FUT-009, FUT-011, FUT-014, FUT-015, FUT-016, FUT-017). -**Deferred** — to v4.3 and later, pending the maintainer's decision: a queue-position and fill-probability model; a strategy-facing exercise instruction; settlement-date accounting; richer contract terms; margin and forced liquidation. +**Deferred** — to v4.3 and later, pending the maintainer's decision: a strategy-facing exercise instruction; settlement-date accounting; richer contract terms; margin and forced liquidation. --- @@ -255,6 +282,15 @@ FUT-009, FUT-014, FUT-015, FUT-016, FUT-017). is one market's equity-option convention and wrong for its index options and for most of the world's; terms are declared or the event is refused (decision 14). +* **A queue position on the pipeline state, or the model on the run + configuration.** Each is where the fact lives, and each moves every run's + `result_id` -- the configuration is part of the recorded run -- for a + capability most runs do not use (decision 15). The book is threaded as the FX + table is and persisted where the execution plan's is. +* **A fill probability drawn per event.** A seeded draw would be reproducible, + but a probability is a calibration of a venue's flow the engine does not have; + the shipped model is deterministic and conservative, and a probabilistic one + is another implementation of the same protocol. * **Drawing assignments.** A probabilistic assignment model is a research assumption with a random state, not an engine fact; assignment before expiry is a supplied input (decision 14). diff --git a/docs/COMPOSING_STRATEGIES.md b/docs/COMPOSING_STRATEGIES.md index f49e69fa..645eb684 100644 --- a/docs/COMPOSING_STRATEGIES.md +++ b/docs/COMPOSING_STRATEGIES.md @@ -258,8 +258,9 @@ identity. * A backtest simulates one venue and one cost model: choosing between venues (`alphalab.execution.routing.select_route`) is evaluated on the live and paper path, not historically. -* Depth updates reach no strategy hook (BDY-003); passive fills have no queue - model (FUT-011). +* Depth updates reach no strategy hook (BDY-003). Passive fills are decided by + a queue model only when one is given (`queue=PriceTimeQueue()`, FUT-011); it + reads the top of the book and signed prints, and draws no fill probability. See [`docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md`](audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md) for what each strategy family needs and where it stands. diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index a0de1287..6ce36d4f 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -82,7 +82,7 @@ deployment reproducible, and is what this release recommends. **The public API.** Every name in `docs/api/public_api.json`: each package's `__all__`, and the `__all__` of `alphalab.api` and of each durable state's -snapshot module -- 2,667 names. `tests/regression/test_public_api_manifest.py` +snapshot module -- 2,678 names. `tests/regression/test_public_api_manifest.py` fails on any name added, removed or re-signed without the manifest changing, and each release's CHANGELOG names every removal or rebinding. Since v4.1 one signature reads the same on every supported interpreter (API-008). A name with a diff --git a/docs/STATE_MODEL.md b/docs/STATE_MODEL.md index 8071a111..ef69d069 100644 --- a/docs/STATE_MODEL.md +++ b/docs/STATE_MODEL.md @@ -215,7 +215,12 @@ an execution plan (ledger FUT-009), the plan's algorithm book, written by parent's algorithm with its children, and every child fill. The plan is taken back by `restore` and held to the book by its `plan_id`, and each algorithm run is rebuilt and checked against its recorded identity. The book is driver state, -not part of `RunState`, so no run's record or `result_id` moved. +not part of `RunState`, so no run's record or `result_id` moved. For a replay +given a queue model (ledger FUT-011) it records the queue book on the same +terms, written by `backtesting.queueing.capture_queue` (`QUEUE_BOOK_SCHEMA = 1`): +the model's type and configuration, and each resting limit order's limit and +the quantity ahead of it. The model is taken back by `restore` and refused when +its type or configuration differs. Three envelopes version only what they add around a payload: `RUN_STATE_ENVELOPE_SCHEMA` (the run-state store, 1), `CHECKPOINT_SCHEMA` (an diff --git a/docs/api/PUBLIC_API.md b/docs/api/PUBLIC_API.md index 58c20b44..38048e24 100644 --- a/docs/api/PUBLIC_API.md +++ b/docs/api/PUBLIC_API.md @@ -23,7 +23,7 @@ durability table. | [`generate_public_api.py`](generate_public_api.py) | Writes the manifest from the package. Keeps the reasons already written; leaves a new shared name's reason empty | | `tests/regression/test_public_api_manifest.py` | Fails on any export added, removed or rebound, on a shared name without a reason, and on a manifest for another release | -At v4.1.0 the manifest records 44 packages and 12 documented modules, 2,667 exports and 35 shared names +At v4.1.0 the manifest records 44 packages and 12 documented modules, 2,678 exports and 35 shared names (`test_public_api_manifest.py` holds this sentence to the manifest). ## Stability from v4.0 diff --git a/docs/api/public_api.json b/docs/api/public_api.json index 622c5a3f..ab1abacf 100644 --- a/docs/api/public_api.json +++ b/docs/api/public_api.json @@ -326,7 +326,7 @@ "StudyResult": "class alphalab.research.study.StudyResult(result_id: 'str', study: 'ResearchStudy', metrics: 'Mapping[str, float]' = , splits: 'SplitReport | None' = None, feature_lineage: 'Mapping[str, str]' = , findings: 'tuple[str, ...]' = , warnings: 'tuple[str, ...]' = , produced_at: 'float' = 0.0) -> None", "TimestampReading": "class alphalab.api.TimestampReading(timestamp_format: 'TimestampFormat', timezone_name: 'str | None', date_policy: 'DateOnlyPolicy | None') -> None", "WireTimestamp": "enum alphalab.api.WireTimestamp[AVAILABILITY, OBSERVATION]", - "backtest": "function alphalab.api.backtest(config: 'RunConfig', dataset: 'Dataset', strategy_state: 'StrategyRuntimeState', context_factory: 'ContextFactory', policy: 'NormalizationPolicy', *, observations: 'Iterable[ObservationDelivery[Any]]' = (), schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), execution: 'ExecutionPlan | None' = None) -> 'BacktestResult'", + "backtest": "function alphalab.api.backtest(config: 'RunConfig', dataset: 'Dataset', strategy_state: 'StrategyRuntimeState', context_factory: 'ContextFactory', policy: 'NormalizationPolicy', *, observations: 'Iterable[ObservationDelivery[Any]]' = (), schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), execution: 'ExecutionPlan | None' = None, queue: 'QueueModel | None' = None) -> 'BacktestResult'", "bond_schedule": "function alphalab.api.bond_schedule(bond: 'Bond', instrument: 'InstrumentRecord', at: 'Callable[[date], float]', *, redemption_price: 'Decimal') -> 'RunSchedule'", "clean_dataset": "function alphalab.api.clean_dataset(dataset: 'Dataset', policy: 'CleaningPolicy') -> 'tuple[tuple[CanonicalRecord, ...], DataQualityReport]'", "combine_datasets": "function alphalab.api.combine_datasets(parts: 'Sequence[tuple[Dataset, NormalizationPolicy]]') -> 'CombinedDataset'", @@ -346,7 +346,7 @@ "observations_from_indicator": "function alphalab.api.observations_from_indicator(metadata: 'IndicatorMetadata', releases: 'Sequence[IndicatorObservation]', source: 'ObservationSource', *, name: 'str | None' = None) -> 'ObservationSet[ExternalObservation]'", "observe": "function alphalab.api.observe(dataset: 'Dataset', source_field: 'FeatureField') -> 'ObservationFrame'", "option_contract_from_spec": "function alphalab.api.option_contract_from_spec(spec: 'OptionSpec') -> 'OptionContract'", - "replay": "function alphalab.api.replay(config: 'RunConfig', dataset: 'Dataset', strategy_state: 'StrategyRuntimeState', context_factory: 'ContextFactory', policy: 'NormalizationPolicy', *, pause_when: 'Callable[[ReplayProgress], bool] | None' = None, observations: 'Iterable[ObservationDelivery[Any]]' = (), schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), execution: 'ExecutionPlan | None' = None) -> 'ReplayResult | PausedReplay'", + "replay": "function alphalab.api.replay(config: 'RunConfig', dataset: 'Dataset', strategy_state: 'StrategyRuntimeState', context_factory: 'ContextFactory', policy: 'NormalizationPolicy', *, pause_when: 'Callable[[ReplayProgress], bool] | None' = None, observations: 'Iterable[ObservationDelivery[Any]]' = (), schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), execution: 'ExecutionPlan | None' = None, queue: 'QueueModel | None' = None) -> 'ReplayResult | PausedReplay'", "resume_replay": "function alphalab.api.resume_replay(paused: 'PausedReplay', context_factory: 'ContextFactory', *, pause_when: 'Callable[[ReplayProgress], bool] | None' = None) -> 'ReplayResult | PausedReplay'", "run_study": "function alphalab.api.run_study(study: 'ResearchStudy', dataset: 'Dataset', price_field: 'FeatureField' = , buckets: 'int' = 5, minimum_assets: 'int' = 5, produced_at: 'float' = 0.0, *, delistings: 'Sequence[DelistingReturn]') -> 'StudyResult'", "sector_classification": "function alphalab.api.sector_classification(registry: 'InstrumentRegistry', asset_ids: 'Sequence[str]', as_of: 'float | None') -> 'Classification'", @@ -378,7 +378,12 @@ "MarketDataset": "class alphalab.backtesting.dataset.MarketDataset(dataset_id: 'str', records: 'tuple[MarketRecord, ...]') -> None", "MarketInput": "alias alphalab.market.quote.Quote | alphalab.market.bar.Bar | alphalab.market.tick.Tick", "MarketRecord": "class alphalab.market.record.MarketRecord(event_id: 'str', timestamp: 'float', payload: 'MarketInput') -> None", - "PausedReplay": "class alphalab.backtesting.replay.PausedReplay(run: 'RunState', cursor: 'ReplayState', cursor_position: 'IdStreamPosition', schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), algorithms: 'AlgorithmBook | None' = None) -> None", + "PausedReplay": "class alphalab.backtesting.replay.PausedReplay(run: 'RunState', cursor: 'ReplayState', cursor_position: 'IdStreamPosition', schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), algorithms: 'AlgorithmBook | None' = None, queue: 'QueueBook | None' = None) -> None", + "PriceTimeQueue": "class alphalab.execution.queue.PriceTimeQueue(count_unsigned_trades: 'bool' = False) -> None", + "QueueBook": "class alphalab.execution.queue.QueueBook(model: 'QueueModel', positions: 'PersistentMap[str, QueuePosition]' = ) -> None", + "QueueModel": "class alphalab.execution.queue.QueueModel(*args, **kwargs)", + "QueueModelError": "class alphalab.backtesting.queueing.QueueModelError", + "QueuedResult": "class alphalab.backtesting.state.QueuedResult(run: 'RunState', queue: 'QueueBook') -> None", "ReplayBacktest": "class alphalab.backtesting.replay.ReplayBacktest()", "ReplayProgress": "class alphalab.backtesting.replay.ReplayProgress(records_replayed: 'int', records_total: 'int', next_timestamp: 'float') -> None", "ReplayResult": "class alphalab.backtesting.state.ReplayResult(backtest: 'BacktestResult', replay_status: 'str', records_replayed: 'int', last_record: 'MarketRecord | None') -> None", @@ -424,11 +429,11 @@ "REPLAY_SCHEMA_HISTORY": "value alphalab.persistence.upgrade.SchemaHistory", "REPLAY_SNAPSHOT_SCHEMA": "value builtins.int", "ReplayEventRecord": "class alphalab.backtesting.replay_snapshot.ReplayEventRecord(event_type: 'str', event: 'ReplaySystemEvent') -> None", - "ReplaySnapshot": "class alphalab.backtesting.replay_snapshot.ReplaySnapshot(run: 'RunSnapshot', session: 'ReplaySession', status: 'ReplayStatus', current_index: 'int', current_timestamp: 'float', real_start_time: 'float', real_current_time: 'float', events: 'tuple[ReplayEventRecord, ...]', cursor_position: 'IdStreamPosition', records_total: 'int', dataset_digest: 'str', last_event_id: 'str | None', next_event_id: 'str', events_dropped: 'int' = 0, schedule_digest: 'str | None' = None, algorithms: 'Mapping[str, Any] | None' = None, schema_version: 'int' = 2) -> None", + "ReplaySnapshot": "class alphalab.backtesting.replay_snapshot.ReplaySnapshot(run: 'RunSnapshot', session: 'ReplaySession', status: 'ReplayStatus', current_index: 'int', current_timestamp: 'float', real_start_time: 'float', real_current_time: 'float', events: 'tuple[ReplayEventRecord, ...]', cursor_position: 'IdStreamPosition', records_total: 'int', dataset_digest: 'str', last_event_id: 'str | None', next_event_id: 'str', events_dropped: 'int' = 0, schedule_digest: 'str | None' = None, algorithms: 'Mapping[str, Any] | None' = None, queue: 'Mapping[str, Any] | None' = None, schema_version: 'int' = 2) -> None", "capture": "function alphalab.backtesting.replay_snapshot.capture(paused: 'PausedReplay') -> 'ReplaySnapshot'", "dataset_digest": "function alphalab.backtesting.replay_snapshot.dataset_digest(dataset: 'MarketDataset') -> 'str'", "from_primitives": "function alphalab.backtesting.replay_snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'ReplaySnapshot'", - "restore": "function alphalab.backtesting.replay_snapshot.restore(snapshot: 'ReplaySnapshot', objects: 'RunObjects', dataset: 'MarketDataset', schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), execution: 'ExecutionPlan | None' = None) -> 'PausedReplay'" + "restore": "function alphalab.backtesting.replay_snapshot.restore(snapshot: 'ReplaySnapshot', objects: 'RunObjects', dataset: 'MarketDataset', schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), execution: 'ExecutionPlan | None' = None, queue: 'QueueModel | None' = None) -> 'PausedReplay'" }, "alphalab.broker": { "ANY_LISTING_VENUE": "value builtins.str", @@ -1139,8 +1144,14 @@ "PerTradeFee": "class alphalab.execution.costs.PerTradeFee(amount: 'Decimal') -> None", "PercentageCommission": "class alphalab.execution.commission.PercentageCommission(rate: decimal.Decimal) -> None", "PercentageSlippage": "class alphalab.execution.slippage.PercentageSlippage(rate: decimal.Decimal) -> None", + "PriceTimeQueue": "class alphalab.execution.queue.PriceTimeQueue(count_unsigned_trades: 'bool' = False) -> None", "ProportionalFee": "class alphalab.execution.costs.ProportionalFee(fraction: 'Decimal') -> None", "ProportionalTax": "class alphalab.execution.costs.ProportionalTax(fraction: 'Decimal', sides: 'frozenset[Side]') -> None", + "QueueBook": "class alphalab.execution.queue.QueueBook(model: 'QueueModel', positions: 'PersistentMap[str, QueuePosition]' = ) -> None", + "QueueModel": "class alphalab.execution.queue.QueueModel(*args, **kwargs)", + "QueueObservation": "class alphalab.execution.queue.QueueObservation(timestamp: 'float', bid: 'Decimal | None' = None, bid_size: 'Decimal | None' = None, ask: 'Decimal | None' = None, ask_size: 'Decimal | None' = None, trade_price: 'Decimal | None' = None, trade_quantity: 'Decimal | None' = None, aggressor: 'Side | None' = None, low: 'Decimal | None' = None, high: 'Decimal | None' = None) -> None", + "QueuePosition": "class alphalab.execution.queue.QueuePosition(limit_price: 'Decimal', ahead: 'Decimal | None') -> None", + "QueueTurn": "class alphalab.execution.queue.QueueTurn(position: 'QueuePosition', eligible: 'Decimal | None') -> None", "QuotedHalfSpread": "class alphalab.execution.costs.QuotedHalfSpread() -> None", "ROUTE_DECISION_SCHEME": "value builtins.str", "ROUTING_POLICY_SCHEME": "value builtins.str", @@ -1544,7 +1555,7 @@ "ROUTING_SETTING_PREFIX": "value builtins.str", "ReconciliationTolerances": "class alphalab.lifecycle.reconciliation.ReconciliationTolerances(order_quantity: 'Tolerance', order_price: 'Tolerance', fill_quantity: 'Tolerance', fill_price: 'Tolerance', commission: 'Tolerance', position_quantity: 'Tolerance', cash: 'Tolerance') -> None", "ReproducibilityAssessment": "class alphalab.lifecycle.reproducibility.ReproducibilityAssessment(manifest_id: 'str', identity_verified: 'bool', gaps: 'tuple[str, ...]', rerun: 'RerunOutcome', rerun_detail: 'tuple[str, ...]', external_requirements: 'tuple[ExternalRequirement, ...]', findings: 'tuple[str, ...]') -> None", - "ReproducibilityManifest": "class alphalab.lifecycle.reproducibility.ReproducibilityManifest(manifest_id: 'str', kind: 'ResultKind', result_id: 'str', dataset_version: 'str', dataset_content_hash: 'str', configuration: 'str', configuration_id: 'str', seed: 'int | None', seed_role: 'SeedRole', engine: 'EngineIdentity', fingerprint: 'StrategyFingerprint | None', build: 'EngineBuild | None' = None, schedule_digest: 'str | None' = None, dataset_parts: 'tuple[tuple[str, str], ...]' = (), execution_plan_id: 'str | None' = None) -> None", + "ReproducibilityManifest": "class alphalab.lifecycle.reproducibility.ReproducibilityManifest(manifest_id: 'str', kind: 'ResultKind', result_id: 'str', dataset_version: 'str', dataset_content_hash: 'str', configuration: 'str', configuration_id: 'str', seed: 'int | None', seed_role: 'SeedRole', engine: 'EngineIdentity', fingerprint: 'StrategyFingerprint | None', build: 'EngineBuild | None' = None, schedule_digest: 'str | None' = None, dataset_parts: 'tuple[tuple[str, str], ...]' = (), execution_plan_id: 'str | None' = None, queue_model: 'str | None' = None) -> None", "RequirementCheck": "class alphalab.lifecycle.portability.RequirementCheck(requirement: 'PortabilityRequirement', outcome: 'RequirementOutcome', detail: 'str', blockers: 'tuple[str, ...]' = ()) -> None", "RequirementOutcome": "enum alphalab.lifecycle.portability.RequirementOutcome[SATISFIED, BLOCKED, NOT_VERIFIED, NOT_APPLICABLE]", "RerunOutcome": "enum alphalab.lifecycle.reproducibility.RerunOutcome[NOT_ATTEMPTED, REPRODUCED, DIVERGED, INPUTS_DIFFER]", @@ -1610,14 +1621,14 @@ "build_fingerprint": "function alphalab.lifecycle.fingerprint.build_fingerprint(name: 'str', strategy_id: 'str', code: 'CodeIdentity', dependencies: 'DependencyManifest', parameters: 'Mapping[str, ParamValue]', research: 'ResearchConfiguration', engine: 'EngineIdentity') -> 'StrategyFingerprint'", "build_specification": "function alphalab.lifecycle.specification.build_specification(strategy: 'StrategyVersionRef', parameters: 'Mapping[str, ParamValue]', datasets: 'Sequence[DatasetAssumption]', risk: 'RiskLimits', capital: 'CapitalPolicy', broker: 'BrokerRequirements', market: 'MarketRequirements', runtime: 'RuntimeRequirements') -> 'DeploymentSpecification'", "canonical_fingerprint_key": "function alphalab.lifecycle.fingerprint.canonical_fingerprint_key(name: 'str', strategy_id: 'str', code: 'CodeIdentity', dependencies: 'DependencyManifest', parameters: 'Mapping[str, ParamValue]', research: 'ResearchConfiguration', engine: 'EngineIdentity') -> 'str'", - "canonical_manifest_key": "function alphalab.lifecycle.reproducibility.canonical_manifest_key(kind: 'ResultKind', result_id: 'str', dataset_version: 'str', dataset_content_hash: 'str', configuration_id: 'str', seed: 'int | None', seed_role: 'SeedRole', engine: 'EngineIdentity', fingerprint: 'str | None', build: 'EngineBuild | None' = None, schedule_digest: 'str | None' = None, dataset_parts: 'tuple[tuple[str, str], ...]' = (), execution_plan_id: 'str | None' = None) -> 'str'", + "canonical_manifest_key": "function alphalab.lifecycle.reproducibility.canonical_manifest_key(kind: 'ResultKind', result_id: 'str', dataset_version: 'str', dataset_content_hash: 'str', configuration_id: 'str', seed: 'int | None', seed_role: 'SeedRole', engine: 'EngineIdentity', fingerprint: 'str | None', build: 'EngineBuild | None' = None, schedule_digest: 'str | None' = None, dataset_parts: 'tuple[tuple[str, str], ...]' = (), execution_plan_id: 'str | None' = None, queue_model: 'str | None' = None) -> 'str'", "certify_strategy": "function alphalab.lifecycle.certification.certify_strategy(fingerprint: 'StrategyFingerprint', specification: 'DeploymentSpecification', evidence: 'CertificationEvidence', budgets: 'Sequence[ResourceBudget]' = (), progression: 'StrategyProgression | None' = None) -> 'CertificationReport'", "code_identity_for": "function alphalab.lifecycle.fingerprint.code_identity_for(registration: 'StrategyRegistration', package: 'str', version: 'str | None', sources: 'Mapping[str, bytes] | None') -> 'CodeIdentity'", "compare_expected_paper_live": "function alphalab.lifecycle.comparison.compare_expected_paper_live(expected: 'RunObservations', paper: 'RunObservations', live: 'RunObservations', tolerances: 'Mapping[ComparisonMetric, Tolerance]') -> 'ThreeWayComparison'", "compare_runs": "function alphalab.lifecycle.comparison.compare_runs(left: 'RunObservations', right: 'RunObservations', tolerances: 'Mapping[ComparisonMetric, Tolerance]') -> 'PairComparison'", "dataset_assumption_from": "function alphalab.lifecycle.specification.dataset_assumption_from(dataset: 'Dataset', role: 'str') -> 'DatasetAssumption'", "deploy_strategy_version": "function alphalab.lifecycle.deployment.deploy_strategy_version(state: 'LifecycleState', governance: 'Governance', name: 'str', version: 'int', environment: 'str', timestamp: 'float', deployed_by: 'str' = '', notes: 'str' = '') -> 'tuple[LifecycleState, DeploymentRef]'", - "derive_manifest_id": "function alphalab.lifecycle.reproducibility.derive_manifest_id(kind: 'ResultKind', result_id: 'str', dataset_version: 'str', dataset_content_hash: 'str', configuration_id: 'str', seed: 'int | None', seed_role: 'SeedRole', engine: 'EngineIdentity', fingerprint: 'str | None', build: 'EngineBuild | None' = None, schedule_digest: 'str | None' = None, dataset_parts: 'tuple[tuple[str, str], ...]' = (), execution_plan_id: 'str | None' = None) -> 'str'", + "derive_manifest_id": "function alphalab.lifecycle.reproducibility.derive_manifest_id(kind: 'ResultKind', result_id: 'str', dataset_version: 'str', dataset_content_hash: 'str', configuration_id: 'str', seed: 'int | None', seed_role: 'SeedRole', engine: 'EngineIdentity', fingerprint: 'str | None', build: 'EngineBuild | None' = None, schedule_digest: 'str | None' = None, dataset_parts: 'tuple[tuple[str, str], ...]' = (), execution_plan_id: 'str | None' = None, queue_model: 'str | None' = None) -> 'str'", "derive_strategy_fingerprint": "function alphalab.lifecycle.fingerprint.derive_strategy_fingerprint(name: 'str', strategy_id: 'str', code: 'CodeIdentity', dependencies: 'DependencyManifest', parameters: 'Mapping[str, ParamValue]', research: 'ResearchConfiguration', engine: 'EngineIdentity') -> 'str'", "differing_parameters": "function alphalab.lifecycle.fingerprint.differing_parameters(left: 'Mapping[str, ParamValue]', right: 'Mapping[str, ParamValue]') -> 'tuple[str, ...]'", "digest_run": "function alphalab.lifecycle.reproducibility.digest_run(result: 'BacktestResult') -> 'RunDigest'", @@ -2530,11 +2541,11 @@ "CheckpointMark": "class alphalab.runtime.checkpoint.CheckpointMark(sequence: 'int', digest: 'str', ends: 'Mapping[str, int]', entries: 'Mapping[str, tuple[tuple[Any, Any], ...]]' = ) -> None", "ChildOrderBindings": "class alphalab.runtime.broker_routing.ChildOrderBindings(to_parent: 'PersistentMap[str, str]' = , by_parent: 'PersistentMap[str, tuple[str, ...]]' = ) -> None", "ChildRoutingResult": "class alphalab.runtime.broker_routing.ChildRoutingResult(broker_state: 'BrokerState', children: 'ChildOrderBindings', order: 'BrokerOrder | None', decision: 'RoutingDecision', events: 'tuple[BrokerEvent, ...]' = ()) -> None", - "ExecutionAssumptions": "class alphalab.runtime.assumptions.ExecutionAssumptions(routing: 'ExecutionRouting', fill_timing: 'FillTiming', fill_policy: 'str', costs: 'str', latency: 'str', frictionless: 'bool', unlimited_liquidity: 'bool') -> None", + "ExecutionAssumptions": "class alphalab.runtime.assumptions.ExecutionAssumptions(routing: 'ExecutionRouting', fill_timing: 'FillTiming', fill_policy: 'str', costs: 'str', latency: 'str', frictionless: 'bool', unlimited_liquidity: 'bool', queue_model: 'str | None' = None) -> None", "ExecutionMode": "enum alphalab.runtime.run.ExecutionMode[BACKTEST, REPLAY, PAPER, LIVE]", "ExecutionPipeline": "class alphalab.runtime.execution_pipeline.ExecutionPipeline()", "ExecutionPipelineConfig": "class alphalab.runtime.execution_pipeline.ExecutionPipelineConfig(account: 'Account', starting_cash: 'Decimal', budget: 'CapitalBudget', allocation_constraints: 'AllocationConstraints', risk_limits: 'RiskLimits', sizing_model: 'SizingModel' = , simulator: 'ExecutionSimulator' = , venue: 'str' = 'SIM', currency: 'str' = '', also_settles: 'frozenset[str]' = frozenset(), routing: 'ExecutionRouting' = , instruments: 'InstrumentRegistry | None' = None, fill_timing: 'FillTiming' = , calendars: 'VenueCalendars' = , retention: 'RetentionPolicy' = ) -> None", - "ExecutionPipelineResult": "class alphalab.runtime.execution_pipeline.ExecutionPipelineResult(state: 'ExecutionPipelineState', market_event: 'MarketEvent', intents: 'tuple[Intent, ...]', order_requests: 'tuple[OrderRequest, ...]', risk_decisions: 'tuple[RiskDecision, ...]', oms_orders: 'tuple[OMSOrder, ...]', execution_reports: 'tuple[ExecutionReport, ...]', fills: 'tuple[CoreFill, ...]', trades: 'tuple[CoreTrade, ...]', unpriced_requests: 'tuple[OrderRequest, ...]' = , valuation: 'PortfolioValuationSnapshot | None' = None, settlement_refusals: 'tuple[SettlementRefusal, ...]' = ) -> None", + "ExecutionPipelineResult": "class alphalab.runtime.execution_pipeline.ExecutionPipelineResult(state: 'ExecutionPipelineState', market_event: 'MarketEvent', intents: 'tuple[Intent, ...]', order_requests: 'tuple[OrderRequest, ...]', risk_decisions: 'tuple[RiskDecision, ...]', oms_orders: 'tuple[OMSOrder, ...]', execution_reports: 'tuple[ExecutionReport, ...]', fills: 'tuple[CoreFill, ...]', trades: 'tuple[CoreTrade, ...]', unpriced_requests: 'tuple[OrderRequest, ...]' = , valuation: 'PortfolioValuationSnapshot | None' = None, settlement_refusals: 'tuple[SettlementRefusal, ...]' = , queue: 'QueueBook | None' = None) -> None", "ExecutionPipelineState": "class alphalab.runtime.execution_pipeline.ExecutionPipelineState(config: 'ExecutionPipelineConfig', market: 'MarketState', strategy: 'StrategyRuntimeState', allocation: 'AllocationState', risk: 'RiskState', oms: 'OMSState', execution: 'ExecutionState', portfolio: 'PortfolioState', analytics: 'AnalyticsState', market_prices: 'Mapping[str, Decimal]' = , fills: 'AppendOnlyLog[CoreFill]' = , trades: 'AppendOnlyLog[CoreTrade]' = , trade_records: 'AppendOnlyLog[TradeRecord]' = , portfolio_snapshots: 'AppendOnlyLog[PortfolioSnapshot]' = , unpriced_assets: 'PersistentMap[str, UnpricedAsset]' = , id_position: 'IdStreamPosition' = ) -> None", "ExecutionRouting": "enum alphalab.runtime.execution_pipeline.ExecutionRouting[SIMULATED, EXTERNAL, ALGORITHMIC]", "HistoryNotRetainedError": "class alphalab.runtime.exceptions.HistoryNotRetainedError", @@ -2564,7 +2575,7 @@ "apply_broker_execution": "function alphalab.runtime.broker_routing.apply_broker_execution(state: 'ExecutionPipelineState', oms_order: 'OMSOrder', execution: 'BrokerExecution', config: 'RoutingConfig', rates: 'FxRates' = FxRates(rates={}, max_age_seconds=None, currency_units=CurrencyUnits({}))) -> 'tuple[ExecutionPipelineState, tuple[CoreFill, ...], tuple[CoreTrade, ...]]'", "checkpoint": "function alphalab.runtime.checkpoint.checkpoint(state: 'RunState', previous: 'CheckpointMark | None' = None) -> 'tuple[str, CheckpointMark]'", "child_broker_order_id": "function alphalab.runtime.broker_routing.child_broker_order_id(parent: 'OMSOrder', sequence: 'int') -> 'str'", - "execution_assumptions": "function alphalab.runtime.assumptions.execution_assumptions(pipeline: 'ExecutionPipelineConfig', fill_policy: 'FillPolicy') -> 'ExecutionAssumptions'", + "execution_assumptions": "function alphalab.runtime.assumptions.execution_assumptions(pipeline: 'ExecutionPipelineConfig', fill_policy: 'FillPolicy', queue_model: 'QueueModel | None' = None) -> 'ExecutionAssumptions'", "execution_report_from_broker": "function alphalab.runtime.broker_routing.execution_report_from_broker(execution: 'BrokerExecution', oms_order: 'OMSOrder', config: 'RoutingConfig') -> 'ExecutionReport'", "live_health": "function alphalab.runtime.live.live_health(state: 'LiveRunState') -> 'tuple[str, ...]'", "read_checkpoints": "function alphalab.runtime.checkpoint.read_checkpoints(payloads: 'Sequence[str]') -> 'RunSnapshot'", @@ -3014,7 +3025,7 @@ "restore": { "bound_to": [ "function alphalab.allocation.snapshot.restore(snapshot: 'AllocationSnapshot') -> 'AllocationState'", - "function alphalab.backtesting.replay_snapshot.restore(snapshot: 'ReplaySnapshot', objects: 'RunObjects', dataset: 'MarketDataset', schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), execution: 'ExecutionPlan | None' = None) -> 'PausedReplay'", + "function alphalab.backtesting.replay_snapshot.restore(snapshot: 'ReplaySnapshot', objects: 'RunObjects', dataset: 'MarketDataset', schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), execution: 'ExecutionPlan | None' = None, queue: 'QueueModel | None' = None) -> 'PausedReplay'", "function alphalab.broker.snapshot.restore(snapshot: 'BrokerSnapshot') -> 'tuple[BrokerState, ExternalOrderMap, ReconciliationLog]'", "function alphalab.instrument.snapshot.restore(snapshot: 'InstrumentRegistrySnapshot') -> 'InstrumentRegistry'", "function alphalab.lifecycle.snapshot.restore(snapshot: 'LifecycleSnapshot', models: 'Mapping[str, object]') -> 'LifecycleState'", diff --git a/docs/audit/FUTURE_READINESS_V4_1_1.md b/docs/audit/FUTURE_READINESS_V4_1_1.md index 2bc5ac01..596ccf01 100644 --- a/docs/audit/FUTURE_READINESS_V4_1_1.md +++ b/docs/audit/FUTURE_READINESS_V4_1_1.md @@ -20,7 +20,9 @@ milestone** put execution algorithms on the historical drivers (FUT-009). **The FUT-013 milestone** made a derivative's lifecycle -- exercise, assignment, expiry, delivery -- the contract's declared terms, carried out by the run in any market a calendar describes, and named the gaps it found (FUT-014 to FUT-017). -Accepted as future work: FUT-011 and FUT-014 to FUT-017, with FUT-002 from v4.1. Every figure below was read from a command's own +**The FUT-011 milestone** made a resting limit order's passive fills depend on a +declared queue model. Accepted as future work: FUT-014 to FUT-017, with FUT-002 +from v4.1. Every figure below was read from a command's own output in the session that produced it. --- @@ -40,19 +42,22 @@ filled by the run's own liquidity cap and cost model and settled on one parent order; declare an option's or a future's terms -- American or European, cash or physical, its deliverable, its exercise rule -- and have the run exercise, assign and deliver it through the portfolio's own accounting, on instants a -declared market calendar gives; and reproduce, replay, pause durably, store and +declared market calendar gives; govern resting limit orders by a declared +queue model, so a passive order fills only once what was displayed ahead of it +has traded or left; and reproduce, replay, pause durably, store and continue every such run to the identical result. The acceptance scenario proves the time semantics against a fixture built to leak, including a control that does leak and is caught. It is **not** a claim that every strategy family is production-ready, nor that any market's rules are supported: AlphaLab ships none, and supports a market when its calendar and contracts are declared (matrix 2.27). Engine gaps remain, each -on the ROADMAP with acceptance criteria: a queue model (FUT-011), a +on the ROADMAP with acceptance criteria: a strategy-facing exercise instruction (FUT-014), settlement-date accounting (FUT-015), contract terms beyond one deliverable and two exercise styles (FUT-016), and margin and forced liquidation (FUT-017); a backtest also simulates one venue, so routing between venues is evaluated only on the live -and paper path. Real venues and +and paper path, and the queue model reads the displayed top of the book, not +depth beyond it, and draws no fill probability. Real venues and data are, as before, the host's. "Future-ready" is therefore claimed only for what sections 6 and 7 evidence. @@ -229,18 +234,22 @@ with that reason, as `rates` already was. | FUT-006, FUT-007 | example 73: features from the study's own definitions, a linear model fitted on purged labels before a boundary, carried as declared parameters and state, traded after | `tests/integration/test_model_on_the_execution_path.py` (12) | | FUT-009 | `alphalab.backtesting.algorithmic`: `ExecutionPlan` (per-asset `AlgorithmAssignment`s, an optional `VenueCheck`) on `BacktestEngine.run`, `ReplayBacktest.run` and the facade; `ExecutionRouting.ALGORITHMIC` holds every parent in the OMS and refuses stop, auction and immediate terms; `ExecutionPipeline.work_child` fills each child by the run's fill policy and cost model and settles it on the parent; the `AlgorithmBook` on the result, in replay schema 2, and the plan's id in the manifest | `tests/integration/test_algorithms_on_the_historical_path.py` (57), `test_execution_algorithm_example.py` (4), two manifest tests; example 74; `benchmarks/benchmark_algorithmic_execution.py` | | FUT-013 | `alphalab.instrument.terms`: `OptionTerms` and `FutureTerms` on `InstrumentRecord.terms` (instrument schema 4, outside the identity key); `Exercise`, `Assignment`, `OptionExpiry` on the schedule and as run operations, through the portfolio's fill authority and each strategy's share; physical futures delivered at their final price; `AssetType.INDEX`; `session_close`, `settles_at` | `tests/integration/test_option_lifecycle.py` (58), `test_lifecycle_conventions.py` (9), `test_lifecycle_example.py` (4); example 75 | +| FUT-011 | `alphalab.execution.queue`: `QueueModel`, `PriceTimeQueue` (price-time priority over displayed liquidity), `QueuePosition`, `QueueTurn`, `QueueObservation`, `QueueBook`; `queue=` on `BacktestEngine.run`, `ReplayBacktest.run` and the facade; `QueuedResult` and `ExecutionAssumptions.queue_model` / `passive_fills`; the book threaded through `RunEngine.advance` and the pipeline step as a parameter (no pipeline, run or config schema moves, no `result_id` moves); `alphalab.backtesting.queueing` (`QUEUE_BOOK_SCHEMA = 1`) carried by a paused replay (schema 2) and held to its model on restore; `ReproducibilityManifest.queue_model` | `tests/integration/test_queue_model.py` (32: every rule of the model, fills against the same run without it, partial fills, depletion short of the front, bars, the fill policy within the queue, whole units, expiry, a split, a triggered stop-limit, taking orders untouched, an unknown queue, replay parity, a pause at every boundary resumed, refusals, an unchanged record); `tests/unit/lifecycle/test_reproducibility.py` (the manifest) | **Guards extended, none weakened.** The replay snapshot's field pin names `PausedReplay.schedule`; the schema-upgrade suites pin instrument schema 4 and replay schema 2 with an upgrade test each, the v4.1.0 payloads frozen in `tests/fixtures` still restoring through them; the persisted-enum pin adds `bond` and `ALGORITHMIC`, the replay field pin `PausedReplay.algorithms`; the -schema-constant guard knows `ALGORITHM_BOOK_SCHEMA`; the ADR-deferral classifier -covers ADR-0051's entries. +schema-constant guard knows `ALGORITHM_BOOK_SCHEMA` and `QUEUE_BOOK_SCHEMA`; the +replay field pin names `PausedReplay.queue` and the frozen v4.1.0 replay payload +upgrades with `queue: null`; the result-field-order pin keeps +`settlement_refusals` where it was, with `queue` appended after it; the +ADR-deferral classifier covers ADR-0051's entries. **Mutation evidence.** Each guard named here was shown to fail against the defect it guards, in a scratch copy of the package imported ahead of the -checkout (`-o pythonpath= --import-mode=append`): in the audit, the replay merging observations *after* closing the slice fails the backtest-replay parity test (after the workload was strengthened so a reading's size depends on the slices seen -- the first version did not distinguish the two orders, which this check found), and v4.1.0's `MarketView.bar` fails four bar tests and four acceptance tests; in the continuation, four mutants, each caught by the test written for it -- a cursor that counts the inputs after a record's instant as applied (`test_scheduled_inputs.py::test_a_replay_paused_anywhere_through_a_payload_keeps_every_input`), a restore that ignores the schedule's digest (`test_heterogeneous_inputs_on_every_driver.py::test_a_payload_resumed_with_another_schedule_or_none_is_refused`), a settlement that leaves orders working (`test_contract_expiry.py::test_an_order_still_working_at_settlement_is_cancelled`), and a manifest that trusts a dataset's stated parts (`test_multi_asset_composition.py::test_a_dataset_stating_parts_it_was_not_derived_from_is_refused`); in the FUT-009 milestone, eight mutants, each caught by `test_algorithms_on_the_historical_path.py` -- a child allowed to work more than its parent has left, a child trading at its release instant, a child outliving its slice, a parent ended without its reservation released, a child fill reported as completing the parent, algorithmic orders filled whole, a book trusting an altered payload, and the capability check skipped. Two of the eight survived the first test file -- the release-instant rule needed two records at one instant, and filling whole showed only under `SAME_EVENT` -- and the tests that catch them were added. In the FUT-013 milestone, twelve mutants, each caught by the lifecycle tests -- a European option exercised early, a put paying the call's payoff, a delivery financed or shorted silently, the exercise threshold ignored, a physical option closed at its intrinsic value (booking it twice), the deliverable's multiplier assumed one, partial-exercise ties given to the later strategy, delivered shares left at the strike, an index made tradable, the registry dropping terms, an exercise beyond the position; the delivered-shares mutant survived the first test, which read the mark only after later prices had re-marked the shares, and the test now reads the equity point at the delivery itself. +checkout (`-o pythonpath= --import-mode=append`): in the audit, the replay merging observations *after* closing the slice fails the backtest-replay parity test (after the workload was strengthened so a reading's size depends on the slices seen -- the first version did not distinguish the two orders, which this check found), and v4.1.0's `MarketView.bar` fails four bar tests and four acceptance tests; in the continuation, four mutants, each caught by the test written for it -- a cursor that counts the inputs after a record's instant as applied (`test_scheduled_inputs.py::test_a_replay_paused_anywhere_through_a_payload_keeps_every_input`), a restore that ignores the schedule's digest (`test_heterogeneous_inputs_on_every_driver.py::test_a_payload_resumed_with_another_schedule_or_none_is_refused`), a settlement that leaves orders working (`test_contract_expiry.py::test_an_order_still_working_at_settlement_is_cancelled`), and a manifest that trusts a dataset's stated parts (`test_multi_asset_composition.py::test_a_dataset_stating_parts_it_was_not_derived_from_is_refused`); in the FUT-009 milestone, eight mutants, each caught by `test_algorithms_on_the_historical_path.py` -- a child allowed to work more than its parent has left, a child trading at its release instant, a child outliving its slice, a parent ended without its reservation released, a child fill reported as completing the parent, algorithmic orders filled whole, a book trusting an altered payload, and the capability check skipped. Two of the eight survived the first test file -- the release-instant rule needed two records at one instant, and filling whole showed only under `SAME_EVENT` -- and the tests that catch them were added. In the FUT-013 milestone, twelve mutants, each caught by the lifecycle tests -- a European option exercised early, a put paying the call's payoff, a delivery financed or shorted silently, the exercise threshold ignored, a physical option closed at its intrinsic value (booking it twice), the deliverable's multiplier assumed one, partial-exercise ties given to the later strategy, delivered shares left at the strike, an index made tradable, the registry dropping terms, an exercise beyond the position; the delivered-shares mutant survived the first test, which read the mark only after later prices had re-marked the shares, and the test now reads the equity point at the delivery itself. In the FUT-011 milestone, fifteen mutants, each caught by `test_queue_model.py` -- prints that leave the queue unconsumed, prints by the order's own side consuming it, a displayed size adding to the queue, a vacated level keeping it, a touch filling behind the queue, a join at the best price put at the front, the queue's cap ignored, its refusal ignored, no join at placement, the book never pruned during the run, a pause not pruned, a triggered stop-limit never joining, whole units not floored, a pause dropping the book, a restore ignoring the model; three survived the first test file -- the cap made the refusal look redundant, and two pruning layers each hid the other -- and the tests that catch them were added. --- @@ -298,24 +307,26 @@ well designed, or that any of it behaves the same against a real venue. ### 8.1 Commands and results on the final tree -Run in the checkout's dev venv (CPython 3.12.4) unless stated. +Run in the checkout's dev venv (CPython 3.12.4) unless stated; the figures are +the FUT-011 milestone's, on the tree it committed. | Gate | Command | Result | | --- | --- | --- | | Lint | `python -m ruff check .` | All checks passed | -| Format | `python -m ruff format --check .` | 1,310 files already formatted | -| Types | `python -m mypy .` | no issues in 1,208 source files | -| Tests | `python -m pytest -W error` | **10,020 passed, 3 failed** -- exactly the three version-keyed guards of section 8.2 (one of them the dev venv's stale metadata); 0 skipped | +| Format | `python -m ruff format --check .` | 1,313 files already formatted | +| Types | `python -m mypy .` | no issues in 1,211 source files | +| Tests | `python -m pytest -W error` | **10,053 passed, 3 failed** -- exactly the three version-keyed guards of section 8.2 (one of them the dev venv's stale metadata); 0 skipped | | Examples | every `examples/[0-9]*.py` under `python -W error` | all 75 exit 0 | -| Certification | `python -W error docs/audit/scripts/certify_release.py --check` | 11 checks passed, matching the re-certified certificate (only the source digest, the host string and the API manifest's digest and export count moved; no determinism, parity, reproducibility or numerical figure) | -| Public API | `python docs/api/generate_public_api.py`, then the manifest tests | 2,667 exports, 35 shared names (2,628 at v4.1.0); additions and defaulted keywords or fields only, none removed or re-signed incompatibly (CHANGELOG `[Unreleased]` lists each) | -| Fresh 3.12 | the suite in a fresh venv from a clean mirror | CPython 3.12.4: **10,021 passed, 2 failed** (the two version-keyed guards; the metadata test passes) | -| CPython 3.13 | the suite on 3.13.17 (python-build-standalone, scratch) | CPython 3.13.17: **10,021 passed, 2 failed** (the same two) | -| CPython 3.14 | the suite on 3.14.9 | CPython 3.14.9: **10,021 passed, 2 failed** (the same two) | +| Certification | `python -W error docs/audit/scripts/certify_release.py --check` | 11 checks passed, matching the re-certified certificate (only the source digest and the API manifest's digest and export count moved; no determinism, parity, reproducibility or numerical figure, and no `result_id`) | +| Public API | `python docs/api/generate_public_api.py`, then the manifest tests | 2,678 exports, 35 shared names (2,628 at v4.1.0); additions and defaulted keywords or fields only, none removed or re-signed incompatibly (CHANGELOG `[Unreleased]` lists each) | +| Fresh 3.12 | the suite in a fresh venv from a clean mirror | CPython 3.12.4: **10,054 passed, 2 failed** (the two version-keyed guards; the metadata test passes) | +| CPython 3.13 | the suite on 3.13.17 (python-build-standalone, scratch) | CPython 3.13.17: **10,054 passed, 2 failed** (the same two) | +| CPython 3.14 | the suite on 3.14.9 | CPython 3.14.9: **10,054 passed, 2 failed** (the same two) | | Build | `python -m build` in the clean mirror | `alphalab-4.1.0.tar.gz` and `alphalab-4.1.0-py3-none-any.whl` (labelled 4.1.0 until the version is set; kept in the scratchpad, `dist/` untouched) | | Distributions | `python -m twine check --strict dist/*` | both PASSED | -| Installed artifacts | `python docs/audit/scripts/release_preflight.py distributions dist --version 4.1.0 --record dist/VERIFICATION-4.1.0.txt` (each artifact alone in a fresh environment, smoke-tested from outside the tree, examples 11 and 70 to 75 with the network refused) | **PASSED**: each artifact installed alone, `pip check` clean, 609 modules, 2,667 public names as the manifest records, examples 11 and 70 to 75 and the consumer contract run with the network refused; wheel `af0a86b8...6d36d`, sdist `73ba43f5...1a1` | -| Benchmarks | `benchmarks/benchmark_{backtesting,replay_engine,runtime,execution_pipeline,strategy_runtime}.py` under `-W error`, on this tree and on `git archive v4.1.0`, one after the other on one host; `benchmark_algorithmic_execution.py` on this tree | no regression on the final tree: judged backtest scaling 4.04x against v4.1.0's 4.03x, replay 1.03x the backtest on both, run layer +2.10% against +2.33% over the bare step, pipeline 4.04x against 4.05x, dispatch 273,635 against 274,714 hooks/s (within run-to-run noise); every parity line identical. `benchmark_algorithmic_execution.py`: judged 4.00x for a 4x workload, working orders in children 1.51x the run filling them whole (ceilings 6.0x and 4.0x) | +| Installed artifacts | `python docs/audit/scripts/release_preflight.py distributions dist --version 4.1.0 --record dist/VERIFICATION-4.1.0.txt` (each artifact alone in a fresh environment, smoke-tested from outside the tree, examples 11 and 70 to 75 with the network refused) | **PASSED**: each artifact installed alone, `pip check` clean, 611 modules, 2,678 public names as the manifest records, examples 11 and 70 to 75 and the consumer contract run with the network refused; wheel `f88dfe1b...6b79`, sdist `b3cb9fec...a354` | +| Benchmarks | `benchmarks/benchmark_{backtesting,replay_engine,runtime,execution_pipeline,strategy_runtime}.py` under `-W error`, on this tree and on `git archive v4.1.0`, one after the other on one host | no regression on the final tree: judged backtest scaling 3.98x against v4.1.0's 4.03x, replay 1.02x the backtest against 1.03x, run layer +2.56% against +2.07% over the bare step (within the run-to-run spread the earlier milestones measured, +2.10% to +2.33%), pipeline 3.99x against 4.01x; every parity line identical. A queue model, measured on its own: a run resting about 150 orders at every one of 6,000 quote and print events costs 7.42 s with `PriceTimeQueue` against 6.84 s without (median of five; 149 fills each) | +| Mutation | the FUT-011 rules, each broken in a scratch copy | 15 of 15 caught by `tests/integration/test_queue_model.py` | | Whitespace | `git diff --check` | clean | ### 8.2 The three guards that wait for the version @@ -336,9 +347,13 @@ depends on it. The version was not changed to make them pass. ### 8.3 What was not run -No hosted CI: Linux was not exercised (the branch is not pushed), nor CodeQL, -the preflight workflow or release validation; this stays outstanding until the -maintainer pushes. Windows remains untested (BDY-027). +Hosted CI ran on the commit before the FUT-011 milestone (`dc508e9`, draft PR +#18): on Ubuntu and macOS under CPython 3.12, 3.13 and 3.14, ruff, format and +mypy passed and pytest reported **10,021 passed, 2 failed** in each of the six +jobs -- the two version-keyed guards of section 8.2 -- so the jobs' later steps +(examples, certification, build, distribution checks) did not run there; CodeQL +passed. The FUT-011 commit's own hosted run is recorded on the PR, not here. +Windows remains untested (BDY-027). --- @@ -346,6 +361,7 @@ maintainer pushes. Windows remains untested (BDY-027). **Recommendation: release this candidate as v4.2.0.** It adds a public module (`alphalab.backtesting.schedule`) and modules (`alphalab.backtesting.algorithmic`, +`alphalab.backtesting.queueing`, `alphalab.execution.queue`, `alphalab.instrument.terms`), names in `alphalab.api`, `alphalab.portfolio`, `alphalab.instrument`, `alphalab.runtime` and `alphalab.backtesting`, three persisted enum members (`AssetType.BOND`, `AssetType.INDEX`, @@ -382,7 +398,6 @@ check them): | --- | --- | --- | --- | | FUT-014 | a strategy hook that instructs an exercise | v4.3 | a strategy-contract addition | | FUT-016 | baskets, cash in lieu, quanto, Bermudan exercise | v4.3 | further declared terms | -| FUT-011 | a queue-position and fill-probability model | v5.0 | new capability; needs depth data (BDY-003) | | FUT-015 | settlement-date (T+n) accounting | v5.0 | a portfolio schema step | | FUT-017 | margin, collateral and forced liquidation | v5.0 | new capability; depends on FUT-015 | | FUT-002 | a lossless typed form of a strategy configuration | v5.0 | a pipeline schema step (predates this audit) | @@ -396,7 +411,7 @@ behaviour stated. checklist is applied; (2) the branch is pushed and hosted CI runs; (3) FUT-014, which lets a strategy's own hooks take the exercise decisions FUT-013 now carries out, on the same lifecycle path; (4) FUT-016, the contract terms the lifecycle -refuses today; (5) FUT-011; (6) FUT-015 then FUT-017, which change the +refuses today; (5) FUT-015 then FUT-017, which change the portfolio's payload and belong with a major release or a stated exception. **External (unchanged):** venue and data-vendor adapters, credentials, market @@ -412,9 +427,12 @@ models, hosted isolation of user-supplied strategy code. cannot be right, not that it is the exchange's. A bond traded at clean prices books no accrual. An exercise's or expiry's settlement value is the host's, applied at the instant its event states. A delivery the book cannot cover - stops the run. A limit child fills by the simulator's resting rule, with no - queue model (FUT-011), so passive execution in a backtest is optimistic; a - child's fill reaches its strategy through the portfolio, not `on_fill`. + stops the run. A run's resting limit orders fill by the simulator's touch rule + unless a queue model is given; `PriceTimeQueue` reads the displayed top of the + book and signed prints, so depth beyond it, hidden liquidity and pro-rata + books are not modelled, and an execution plan's limit children are not + governed by it; a child's fill reaches its strategy through the portfolio, + not `on_fill`. * *Numerical.* Unchanged: certified identities per host class for the standalone engines (LIM-005); the construction solver is cubic (PRF-005). * *Performance.* `MarketView.bar` is linear in the latest-bar index per call; a diff --git a/docs/audit/PRE_V4_COMPLETION_LEDGER.yaml b/docs/audit/PRE_V4_COMPLETION_LEDGER.yaml index 12b9de01..14660c9e 100644 --- a/docs/audit/PRE_V4_COMPLETION_LEDGER.yaml +++ b/docs/audit/PRE_V4_COMPLETION_LEDGER.yaml @@ -5706,25 +5706,24 @@ findings: candidate_outcome: "Delivered; clean-price booking with accrued interest as a separate cash leg is not modelled (stated)." - id: "FUT-011" category: "future_work" - subsystem: "execution" - module: "alphalab/execution" + subsystem: "execution; backtesting" + module: "alphalab/execution/queue.py; alphalab/backtesting/queueing.py; alphalab/runtime/execution_pipeline.py" finding: "No queue-position or fill-probability model: a resting limit order fills by the simulator's rule." evidence: "v4.1.1 future-readiness audit (docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md)." severity: "low" - current_state: "As of the v4.1.1 candidate." - required_change: "FUTURE (ROADMAP 'Future work'). Limitation: passive fills are decided without a queue. Impact: a market-making or passive-execution backtest is optimistic about passive fills. Reason for deferral: a queue model needs depth data and a calibrated model; that is new engine capability, and depth is not a strategy hook (BDY-003). Interim safe behaviour: treat passive fills as an optimistic assumption and size conclusions accordingly. Target horizon: v5.0 Dependencies: depth data; BDY-003. Completion criteria: a declared queue model decides passive fills, named in the result's execution assumptions, with its own tests." - rationale: "Deferred work kept where it is tracked is work." + current_state: "As of the v4.2.0 candidate, before the FUT-011 milestone." + required_change: "DELIVERED (v4.2.0 candidate): a QueueModel (alphalab.execution.queue) given to BacktestEngine.run, ReplayBacktest.run or the facade as queue= decides which resting limit orders have reached the front of their price level. Each governed order -- a limit order, or a stop-limit once triggered, neither immediate nor an auction order -- keeps a QueuePosition (its limit and the quantity ahead of it, or unknown) in a QueueBook the driver threads through RunEngine.advance and ExecutionPipeline.process_market_event as a parameter, as the FX table is, so no pipeline, run or config schema moves and a run without one keeps its record and result_id. It joins at the event it was placed (or triggered) at; at each later event of its asset, before the strategy decides there, the model advances it from a QueueObservation of that event alone (quote, print with its reported aggressor, bar range) and returns a QueueTurn: what of the order may fill. The run's fill policy decides within that, and the fill is priced as a maker and settled through the canonical OMS, portfolio, reservation and attribution path. PriceTimeQueue models FIFO price-time priority over displayed liquidity: at the best price everything shown is ahead, inside the spread nothing, behind it unknown; prints at the limit by the other side consume the queue first and what trades beyond it may fill; a smaller displayed size bounds the queue and a larger one never adds to it; a best price beyond the level empties it; trading through the limit clears it; a touch fills only an order at the front; an unsigned print counts only with count_unsigned_trades. The result is a QueuedResult whose execution_assumptions name the model (ExecutionAssumptions.queue_model, passive_fills, and the setting execution.queue_model only when declared); manifest_for_run records ReproducibilityManifest.queue_model, rendered into the identity only when present; a paused replay carries the book (replay schema 2, QUEUE_BOOK_SCHEMA 1) and restore(..., queue=) refuses a missing, extra or differently configured model. Refused beside an execution plan (QueueModelError) and with any routing but SIMULATED (RuntimeValidationError). Bounded, and stated: one venue's price-time priority over the displayed top of book; pro-rata or size priority is another QueueModel, not a parameter; hidden and iceberg liquidity, self-trade prevention, keep-priority amends (the simulated path has no amend) and depth beyond the top of the book are not modelled; the run's own resting orders are not in the displayed feed, so each is queued against the market's displayed queue independently; an order placed outside a market event of its asset starts from an unknown queue; no fill probability is drawn -- the model is deterministic, and a probabilistic one would implement the protocol." + rationale: "A passive fill is decided by what reached the front of the queue, declared and named beside the result, rather than by the market touching the limit." release: "v4.2.0" - implementation_status: "accepted as future work (v5.0, proposed by the v4.1.1 audit, pending the maintainer's decision): ROADMAP Future work FUT-011" - test_status: "n/a" - documentation_status: "ROADMAP.md (Future work); docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md" - benchmark_status: "n/a" - api_impact: "additive" - architecture_impact: "local" - performance_impact: "neutral" - target_horizon: "v5.0" - verified: "v4.1.1 audit" - candidate_outcome: "Accepted as future work, with the decision it needs named." + implementation_status: "implemented (v4.2.0 candidate)" + test_status: "tests/integration/test_queue_model.py::test_a_resting_buy_fills_only_what_trades_beyond_the_queue_ahead_of_it" + documentation_status: "docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md; docs/audit/FUTURE_READINESS_V4_1_1.md; docs/audit/STRATEGY_CAPABILITY_MATRIX.md; docs/STATE_MODEL.md" + benchmark_status: "measured in the FUT-011 milestone (docs/audit/FUTURE_READINESS_V4_1_1.md)" + api_impact: "additive (a module, a result type, keyword-only parameters, a trailing result field)" + architecture_impact: "local" + performance_impact: "a run given no queue model is unchanged; one given a model costs about 9% more on a workload resting about 150 orders at every one of 6,000 events" + verified: "v4.1.1 FUT-011 milestone: 32 integration tests (the model rule by rule; fills, partial fills, depletion short of the front, touch and trade-through on bars, the fill policy within the queue, whole units, expiry, a split ending a resting order, a triggered stop-limit, untouched taking orders, an order joining from an unknown queue, replay parity, a replay paused at every boundary resumed from its payload, a model refused on restore, refusals beside a plan and on a venue route, an unchanged record) and a manifest test; fifteen mutants of the rules each caught" + candidate_outcome: "Delivered: a declared, deterministic price-time queue model on both historical drivers; a fill-probability model is not shipped and would implement the same protocol." - id: "FUT-012" category: "future_work" subsystem: "instrument; portfolio; runtime" diff --git a/docs/audit/RELEASE_CERTIFICATION.md b/docs/audit/RELEASE_CERTIFICATION.md index ad97eaff..c01f9f41 100644 --- a/docs/audit/RELEASE_CERTIFICATION.md +++ b/docs/audit/RELEASE_CERTIFICATION.md @@ -7,7 +7,7 @@ Generated by `python docs/audit/scripts/certify_release.py` (ledger FEA-006) fro | Build | | | --- | --- | | Engine | alphalab 4.1.0 | -| Source digest | `a9ec495689a4d52df59e95b1d691263bbcd3d678123ce583498ed5eee0b9ce7a` | +| Source digest | `eaa972bc7510d6d46d67f5173dbb129856c21ecf7c1164e1d19093ada6796684` | | Time-zone database | 2024a | | Certified on | Python 3.12.4 (CPython), macOS-27.0.1-arm64-arm-64bit | @@ -105,10 +105,10 @@ Generated by `python docs/audit/scripts/certify_release.py` (ledger FEA-006) fro ### API-1 -- `manifest_digest`: `074a8d0cde302752eb3ce92ad9699548fe46dd17331a9ebf674ee121068f5537` +- `manifest_digest`: `c288b0cff1e17b3504c4c62cc38bc41d5c0b42b19417bdabd72a710d6ec0da74` - `packages`: `44` - `documented_modules`: `12` -- `exports`: `2667` +- `exports`: `2678` - `shared_names`: `35` ## What this does not certify diff --git a/docs/audit/STRATEGY_CAPABILITY_MATRIX.md b/docs/audit/STRATEGY_CAPABILITY_MATRIX.md index ca518702..c9fd8c32 100644 --- a/docs/audit/STRATEGY_CAPABILITY_MATRIX.md +++ b/docs/audit/STRATEGY_CAPABILITY_MATRIX.md @@ -244,7 +244,7 @@ dependencies; **(12)** acceptance criteria for "ready". | (5) | common, core. | | (6) | runtime, broker, backtesting, lifecycle. | | (7) | B, Re, P (simulated venue). | -| (8) | **VERIFIED**. No queue-position or fill-probability model: **MISSING** (section 2.18). | +| (8) | **VERIFIED**. A declared queue model decides passive fills on both historical drivers (v4.2.0 candidate, FUT-011): **VERIFIED** for price-time priority over displayed liquidity (`PriceTimeQueue`); a fill-probability model: not shipped (section 2.18). | | (9) | `tests/integration/test_v33_capabilities.py`, `tests/regression/test_v33_invariants.py`; v4.1.1: example 72 runs with `NEXT_EVENT` timing, a 10% participation cap and a six-role cost model, and `execution_assumptions.optimistic == ()`. | | (10) | Defaults are optimistic and are *stated*, not hidden (`result.execution_assumptions`). | | (11) | none. | @@ -263,7 +263,7 @@ dependencies; **(12)** acceptance criteria for "ready". | (7) | B, Re, L (and paper through `PaperBroker`). Paper over a source (`TradingSession`) routes to its broker, not to an algorithm book. | | (8) | **VERIFIED** on the live path and, since the FUT-009 milestone, on both historical drivers (was **PARTIAL**: a backtest filled every order whole). Each parent is one OMS order, held under `ALGORITHMIC` routing -- risk judged it whole, allocation reserved it -- and worked in children at its asset's events; each child is filled by the run's own fill policy and cost model (`work_child`) and settled on the parent through the canonical report path, so the OMS, portfolio, reservation and attributed trade records see one order. TWAP, VWAP, slicing, iceberg and participation (`LEAVE_UNFILLED`) are evidenced; participation with `COMPLETE_AT_END` is refused by the plan. | | (9) | `tests/integration/test_algorithms_on_the_historical_path.py` (57: quantities reconcile, nothing overfills, partial fills and catch-up, no liquidity, rejection, a venue declaration refusing a child, a split cancelling the parent, incomplete at the data's end, both fill timings, no look-ahead in price or volume, costs per fill, attribution of a netted parent, replay parity, a replay paused at every boundary resumed from its payload, the same children the live route sends a `PaperBroker`); `tests/integration/test_execution_algorithm_example.py`; `examples/74_evaluate_an_execution_algorithm.py`; `test_v39_capabilities.py` for the live path. Eight mutants of the guards, each caught. | -| (10) | Passive (limit) children fill by the simulator's resting rule, with no queue model (FUT-011). A child's fill reaches its strategy through the portfolio, not `on_fill`, as a venue fill does live. A `PaperBroker` fills a market child at the parent's reference price, so the backtest equals the paper route child for child, not price for price (ADR-0051 decision 13). | +| (10) | Passive (limit) children fill by the simulator's resting rule: a queue model governs a run's own orders, not an execution plan's children, and is refused beside a plan (FUT-011). A child's fill reaches its strategy through the portfolio, not `on_fill`, as a venue fill does live. A `PaperBroker` fills a market child at the parent's reference price, so the backtest equals the paper route child for child, not price for price (ADR-0051 decision 13). | | (11) | Done (FUT-009). | | (12) | Met: an algorithm's children are evaluated by `BacktestEngine` and `ReplayBacktest` with the run's own fills, child for child the ones the live route sends. | @@ -497,10 +497,10 @@ dependencies; **(12)** acceptance criteria for "ready". | (4) | `alphalab.market`. | | (5)-(6) | market. | | (7) | Depth reaches market state; **not** a strategy hook (BOUNDARY BDY-003). Quotes and trades do. | -| (8) | Quote/trade-driven strategies (spread, imbalance from top of book, inventory via positions): supported through `on_quote`/`on_trade`. Depth-driven strategies: **BOUNDARY** BDY-003. Queue position and fill probability: **MISSING**. Latency-sensitive trading: **BOUNDARY** (single-threaded, host-driven; LIM-006). | +| (8) | Quote/trade-driven strategies (spread, imbalance from top of book, inventory via positions): supported through `on_quote`/`on_trade`. Depth-driven strategies: **BOUNDARY** BDY-003. Queue position: **VERIFIED** with a declared `PriceTimeQueue` (FUT-011) -- top of book and signed prints, not depth beyond it; fill probability: not modelled. Latency-sensitive trading: **BOUNDARY** (single-threaded, host-driven; LIM-006). | | (9) | `tests/integration/test_trade_prints.py`, `test_streaming_session.py`. | -| (10) | A market-making backtest fills resting limits by the simulator's rule, not a queue model -- optimistic, and stated by `execution_assumptions`. | -| (11) | FUT-011 (queue/fill-probability model: new engine capability). | +| (10) | Without a queue model a market-making backtest fills resting limits when the market reaches them -- optimistic, and stated by `execution_assumptions.passive_fills`; with one, only what reached the front of the level fills, and the model is named on the result and the manifest (`tests/integration/test_queue_model.py`). | +| (11) | none (FUT-011 delivered); pro-rata books, depth-driven queues and a fill probability would be further queue models. | | (12) | -- | ### 2.23 Reproducibility, lineage and research-result identity diff --git a/docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md b/docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md index 8202d465..0e789971 100644 --- a/docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md +++ b/docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md @@ -7,9 +7,9 @@ component-level evidence is [`STRATEGY_CAPABILITY_MATRIX.md`](STRATEGY_CAPABILIT gap is a ledger entry and a ROADMAP "Future work" item. The audit raised FUT-003 to FUT-012; its continuation delivered FUT-003 to FUT-008, FUT-010 and FUT-012 and raised FUT-013, the FUT-009 milestone delivered FUT-009, and the -FUT-013 milestone delivered FUT-013 and raised FUT-014 to FUT-017, so what is -open is FUT-011 and FUT-014 to FUT-017 (FUT-002 predates the audit). Rows below -are as of the FUT-013 milestone. +FUT-013 milestone delivered FUT-013 and raised FUT-014 to FUT-017, and the +FUT-011 milestone delivered FUT-011, so what is open is FUT-014 to FUT-017 +(FUT-002 predates the audit). Rows below are as of the FUT-011 milestone. ## Classes @@ -95,9 +95,9 @@ adapter over real HTTP (matrix 2.24). This is not repeated in each row. | Family | Class | Evidence / what is missing | | --- | --- | --- | -| Market making, inventory control | **D** for honest evaluation | Quotes, resting limits, positions and costs are on the path; passive fills have no queue model, so results are optimistic (FUT-011). | +| Market making, inventory control | **C** | Quotes, resting limits, positions and costs are on the path; a declared queue model (`PriceTimeQueue`, FUT-011) fills a resting order only once the displayed queue ahead of it has traded or left, so passive fills are no longer optimistic by construction. Depth beyond the top of the book and pro-rata books are not modelled. | | Order-flow imbalance, liquidity, spread signals | **A** from top of book and trade prints (`on_quote`, `on_trade`; `test_trade_prints.py`); depth-driven signals are a boundary (BDY-003). | -| Queue-aware execution, fill probability | **D** | FUT-011. | +| Queue-aware execution, fill probability | **A** for queue position, **C** for fill probability | A resting limit order's place in a price-time queue decides its passive fills on both drivers, replay and pause included (`test_queue_model.py`, FUT-011). A fill-probability model is a model the researcher supplies: `QueueModel` is a protocol, and AlphaLab ships only the deterministic price-time one. | | Slicing: TWAP, VWAP, participation, slicing, iceberg | **A** | On the live and paper path (`test_v39_capabilities.py`) and, since the FUT-009 milestone, on both historical drivers: each child filled by the run's fill policy and cost model and settled on one parent order, its implementation shortfall measurable (`test_algorithms_on_the_historical_path.py`, example 74). Participation that completes after its window is refused. | | Smart order routing across venues | **A** on the live and paper path (`select_route`, `test_v39_capabilities.py`); **D** historically -- a run simulates one venue and one cost model, so a choice between venues is not evaluated in a backtest. | | Latency-sensitive strategies | **E** | The engine is single-threaded and driven by its host (LIM-006); co-location, kernel-bypass feeds and venue latency are infrastructure no library can guarantee. | @@ -136,8 +136,8 @@ that the seams hold: example 72 combines a price signal (1), a macro regime (3), social sentiment (4), a futures hedge (5), a researcher-defined algorithm (7) and position sizing with a risk gate (8), through one run, one replay and one continuation, with the time semantics of each input preserved. What breaks -a combination today is one of the named gaps -- FUT-011 (a queue model), -FUT-016 (contract terms beyond one deliverable) or FUT-017 (margin) -- and never +a combination today is one of the named gaps -- FUT-016 (contract terms beyond +one deliverable) or FUT-017 (margin) -- and never a missing strategy template; how its orders are executed is now part of what a backtest measures (FUT-009). Example 72 now carries every timed input on one schedule, and its replay pauses through a payload. @@ -146,7 +146,6 @@ on one schedule, and its replay pauses through a payload. | Work | Families it moves | Release class | | --- | --- | --- | -| FUT-011 queue model | market making, passive execution | minor, large | | FUT-014 a strategy-facing exercise instruction | early-exercise and volatility strategies deciding inside the run | minor | | FUT-016 richer contract terms | adjusted contracts after mergers, quanto options, Bermudan contracts | minor | | FUT-015 / FUT-017 settlement-date accounting, margin | cash-constrained and short-option strategies, physically delivered commodities | major or a stated exception (payload steps) | diff --git a/docs/audit/release_certification.json b/docs/audit/release_certification.json index c51c3b88..08bd1f79 100644 --- a/docs/audit/release_certification.json +++ b/docs/audit/release_certification.json @@ -2,7 +2,7 @@ "release": "4.1.0", "build": { "engine": "alphalab 4.1.0", - "source_digest": "a9ec495689a4d52df59e95b1d691263bbcd3d678123ce583498ed5eee0b9ce7a", + "source_digest": "eaa972bc7510d6d46d67f5173dbb129856c21ecf7c1164e1d19093ada6796684", "tz_database": "2024a" }, "environment": { @@ -133,10 +133,10 @@ "claim": "The build's public API is the one docs/api/public_api.json records for this release.", "passed": true, "evidence": { - "manifest_digest": "074a8d0cde302752eb3ce92ad9699548fe46dd17331a9ebf674ee121068f5537", + "manifest_digest": "c288b0cff1e17b3504c4c62cc38bc41d5c0b42b19417bdabd72a710d6ec0da74", "packages": 44, "documented_modules": 12, - "exports": 2667, + "exports": 2678, "shared_names": 35 } } diff --git a/tests/integration/test_queue_model.py b/tests/integration/test_queue_model.py new file mode 100644 index 00000000..6c08ae08 --- /dev/null +++ b/tests/integration/test_queue_model.py @@ -0,0 +1,610 @@ +"""A queue model decides which resting limit orders reached the front (v4.1.1, ledger FUT-011). + +Two halves. The model itself -- :class:`~alphalab.execution.queue.PriceTimeQueue`, +a pure function of a position and one event -- is held rule by rule. Then the +model on the historical drivers: what fills, when and how much, against the +same run without it; that the fills go through the canonical path; that a +replay equals the backtest and a replay paused at any boundary resumes to the +same queue and result; what is refused; and that a run declaring no model is +recorded exactly as it was. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterable, Sequence +from dataclasses import replace +from decimal import Decimal +from typing import Any + +import pytest + +from alphalab.backtesting import ( + AlgorithmAssignment, + BacktestEngine, + ExecutionPlan, + MarketDataset, + PausedReplay, + PriceTimeQueue, + QueueBook, + QueuedResult, + QueueModelError, + ReplayBacktest, + ReplayResult, + RunSchedule, + ScheduledSplit, +) +from alphalab.backtesting.engine import initialize +from alphalab.backtesting.queueing import QUEUE_BOOK_SCHEMA, capture_queue, queue_from_primitives +from alphalab.backtesting.replay_snapshot import capture as capture_replay +from alphalab.backtesting.replay_snapshot import from_primitives as replay_from_primitives +from alphalab.backtesting.replay_snapshot import restore as restore_replay +from alphalab.common.ids import id_scope +from alphalab.common.order_terms import MARKET, OrderTerms, TimeInForce +from alphalab.core.enums import OrderStatus, Side +from alphalab.data.feed import TradeAggressor +from alphalab.execution.algorithms import TWAP, Urgency +from alphalab.execution.fill import FillStatus +from alphalab.execution.policy import FillTiming, LiquidityCappedFill, StaticFill +from alphalab.execution.queue import QueueObservation, QueuePosition +from alphalab.lifecycle import digest_run +from alphalab.market.bar import Bar, TimeFrame +from alphalab.market.quote import Quote +from alphalab.market.tick import Tick +from alphalab.persistence import deserialize, serialize +from alphalab.persistence.exceptions import StateDecodeError +from alphalab.portfolio.corporate_actions import Split +from alphalab.runtime.exceptions import RuntimeValidationError +from alphalab.runtime.run import ExecutionMode, RunConfig, RunEngine +from alphalab.runtime.run_snapshot import RunObjects +from alphalab.runtime.snapshot import RuntimeObjects +from alphalab.strategy.context import StrategyContext +from alphalab.strategy.events import Intent +from alphalab.strategy.protocol import BaseStrategy +from alphalab.strategy.runtime import create_runtime, register_strategy +from alphalab.strategy.state import RuntimeState as StrategyRuntimeState +from alphalab.strategy.supervisor import RuntimeSupervisor +from tests.integration.harness import backtest_config, context_factory, equity, registry_of + +ACME_RECORD = equity("ACME") +ACME = ACME_RECORD.asset_id +T0 = 1_700_000_000.0 +LIMIT = Decimal("99.99") +QUEUE = PriceTimeQueue() +D = Decimal +HUNDRED = Decimal("100") + + +def quote(t: int, bid: str, bid_size: str, ask: str, ask_size: str) -> Quote: + return Quote(ACME, T0 + t, D(bid), D(ask), D(bid_size), D(ask_size), "SIM", "USD") + + +def tick(t: int, price: str, quantity: str, side: TradeAggressor | None) -> Tick: + return Tick(ACME, T0 + t, D(price), D(quantity), f"P{t}", "SIM", "USD", side) + + +def bar(t: int, open_: str, high: str, low: str, close: str) -> Bar: + return Bar( + ACME, T0 + t, D(open_), D(high), D(low), D(close), D("1000"), None, None, TimeFrame.M1 + ) + + +SELLER = TradeAggressor.SELLER + +#: A buy at 99.99 joins behind the 300 shown there; sellers then hit the bid -- +#: 120, 200 (20 beyond the queue), 50 -- and the offer comes down to the limit. +DEPLETION = ( + quote(0, "99.99", "300", "100.01", "300"), + tick(1, "99.99", "120", SELLER), + tick(2, "99.99", "200", SELLER), + tick(3, "99.99", "50", SELLER), + quote(4, "99.98", "500", "99.99", "40"), +) + + +class Poster(BaseStrategy): + """Places one order -- by default a buy of 100 at the limit -- at the first instant. + + Stateless, so a replay restored with a fresh instance continues as it was. + """ + + def __init__(self, terms: OrderTerms | None = None, quantity: Decimal = HUNDRED) -> None: + self._terms = OrderTerms.limit(LIMIT) if terms is None else terms + self._quantity = quantity + + def _place(self, timestamp: float) -> Iterable[Intent]: + if timestamp != T0: + return () + return (Intent("S", ACME, self._quantity, terms=self._terms, timestamp=timestamp),) + + def on_quote(self, context: StrategyContext, event: Any) -> Iterable[Intent]: + return self._place(event.timestamp) + + def on_bar(self, context: StrategyContext, event: Any) -> Iterable[Intent]: + return self._place(event.timestamp) + + def on_tick(self, context: StrategyContext, event: Any) -> Iterable[Intent]: + return self._place(event.timestamp) + + +def strategies(poster: Poster | None = None) -> StrategyRuntimeState: + state = register_strategy(create_runtime(), "S", Poster() if poster is None else poster) + running = {} + for strategy_id, entry in state.strategies.items(): + configured, _ = RuntimeSupervisor.configure(entry, {}, 1.0) + initialized, _ = RuntimeSupervisor.initialize(configured, 1.1) + subscribed, _ = RuntimeSupervisor.subscribe(initialized, frozenset({"*"}), 1.2) + running[strategy_id], _ = RuntimeSupervisor.start(subscribed, 1.3) + return replace(state, strategies=running) + + +def config(*, whole_units: bool = False, **settings: Any) -> RunConfig: + base = backtest_config("S", **settings) + constraints = replace( + base.pipeline.allocation_constraints, enforce_integer_quantities=whole_units + ) + pipeline = replace( + base.pipeline, + fill_timing=FillTiming.NEXT_EVENT, + instruments=registry_of(ACME_RECORD), + allocation_constraints=constraints, + ) + return replace(base, pipeline=pipeline) + + +def dataset(records: Sequence[Quote | Tick | Bar], name: str = "QUEUE") -> MarketDataset: + return MarketDataset.of(name, list(records)) + + +def run( + records: Sequence[Quote | Tick | Bar] = DEPLETION, + model: PriceTimeQueue | None = QUEUE, + poster: Poster | None = None, + **settings: Any, +) -> Any: + return BacktestEngine.run( + config(**settings), dataset(records), strategies(poster), context_factory, queue=model + ) + + +def fills(result: Any) -> list[tuple[float, Decimal, Decimal, str]]: + return [ + (report.timestamp - T0, report.fill_quantity, report.fill_price, report.liquidity_flag) + for report in result.state.execution.history + ] + + +def only_order(result: Any) -> Any: + (order,) = result.state.oms.orders.orders() + return order + + +def at(t: int, **shown: Any) -> QueueObservation: + return QueueObservation(T0 + t, **shown) + + +# --------------------------------------------------------------------------- # +# The model, rule by rule +# --------------------------------------------------------------------------- # + + +def test_an_order_better_than_the_best_price_has_nothing_ahead() -> None: + shown = at(0, bid=D("99.98"), bid_size=D("500"), ask=D("100.01"), ask_size=D("10")) + assert QUEUE.join(Side.BUY, LIMIT, shown) == QueuePosition(LIMIT, D("0")) + # A sell inside the spread, symmetrically. + assert QUEUE.join(Side.SELL, D("100.00"), shown) == QueuePosition(D("100.00"), D("0")) + + +def test_an_order_at_the_best_price_queues_behind_everything_shown_there() -> None: + shown = at(0, bid=LIMIT, bid_size=D("300"), ask=D("100.01"), ask_size=D("40")) + assert QUEUE.join(Side.BUY, LIMIT, shown) == QueuePosition(LIMIT, D("300")) + assert QUEUE.join(Side.SELL, D("100.01"), shown) == QueuePosition(D("100.01"), D("40")) + + +def test_behind_the_best_price_or_without_a_quote_the_queue_is_unknown() -> None: + behind = at(0, bid=D("100.00"), bid_size=D("300"), ask=D("100.01"), ask_size=D("40")) + assert QUEUE.join(Side.BUY, LIMIT, behind).ahead is None + assert QUEUE.join(Side.BUY, LIMIT, at(0, low=D("99"), high=D("101"))).ahead is None + assert ( + QUEUE.join(Side.BUY, LIMIT, at(0, trade_price=LIMIT, trade_quantity=D("5"))).ahead is None + ) + + +def test_a_print_at_the_limit_consumes_the_queue_before_the_order() -> None: + position = QueuePosition(LIMIT, D("300")) + hit = at(1, trade_price=LIMIT, trade_quantity=D("120"), aggressor=Side.SELL) + short = QUEUE.advance(position, Side.BUY, hit) + assert short.position.ahead == D("180") and short.eligible == D("0") and not short.may_fill + beyond = QUEUE.advance( + short.position, + Side.BUY, + at(2, trade_price=LIMIT, trade_quantity=D("200"), aggressor=Side.SELL), + ) + assert beyond.position.ahead == D("0") and beyond.eligible == D("20") + + +def test_a_print_on_the_orders_own_side_or_unsigned_does_not_consume_it_by_default() -> None: + position = QueuePosition(LIMIT, D("300")) + lifted = at(1, trade_price=LIMIT, trade_quantity=D("500"), aggressor=Side.BUY) + unsigned = at(1, trade_price=LIMIT, trade_quantity=D("500")) + for shown in (lifted, unsigned): + turn = QUEUE.advance(position, Side.BUY, shown) + assert turn.position.ahead == D("300") and not turn.may_fill + # Declared otherwise, an unsigned print counts -- the declaration is the model's. + counting = PriceTimeQueue(count_unsigned_trades=True) + assert counting.advance(position, Side.BUY, unsigned).eligible == D("200") + assert counting.advance(position, Side.BUY, lifted).eligible == D("0") + + +@pytest.mark.parametrize( + "shown", + [ + at(1, bid=D("99.97"), bid_size=D("1"), ask=D("99.98"), ask_size=D("1")), + at(1, trade_price=D("99.98"), trade_quantity=D("1")), + at(1, low=D("99.98"), high=D("100.5")), + ], + ids=["quote", "print", "bar"], +) +def test_the_market_trading_through_the_limit_clears_the_queue(shown: QueueObservation) -> None: + turn = QUEUE.advance(QueuePosition(LIMIT, None), Side.BUY, shown) + assert turn.eligible is None and turn.position.ahead == D("0") + + +def test_a_sell_is_cleared_by_the_market_trading_above_it() -> None: + limit = D("100.01") + turn = QUEUE.advance( + QueuePosition(limit, D("40")), Side.SELL, at(1, low=D("100"), high=D("100.02")) + ) + assert turn.eligible is None + touch = QUEUE.advance(QueuePosition(limit, D("40")), Side.SELL, at(1, low=D("99"), high=limit)) + assert not touch.may_fill + + +def test_touching_the_limit_fills_only_an_order_at_the_front() -> None: + touch = at(1, low=LIMIT, high=D("100.5")) + assert not QUEUE.advance(QueuePosition(LIMIT, D("1")), Side.BUY, touch).may_fill + assert not QUEUE.advance(QueuePosition(LIMIT, None), Side.BUY, touch).may_fill + assert QUEUE.advance(QueuePosition(LIMIT, D("0")), Side.BUY, touch).eligible is None + + +def test_the_displayed_level_bounds_the_queue_and_never_adds_to_it() -> None: + position = QueuePosition(LIMIT, D("300")) + + def shown(bid: str, size: str) -> QueueObservation: + return at(1, bid=D(bid), bid_size=D(size), ask=D("100.01"), ask_size=D("10")) + + # Fewer shown at the level: some of what was ahead has gone. + assert QUEUE.advance(position, Side.BUY, shown("99.99", "120")).position.ahead == D("120") + # More shown: arrivals behind the order; nothing ahead of it is added. + assert QUEUE.advance(position, Side.BUY, shown("99.99", "900")).position.ahead == D("300") + # The best bid below the level: nothing is displayed there, so nothing is ahead. + assert QUEUE.advance(position, Side.BUY, shown("99.98", "900")).position.ahead == D("0") + # The best bid above the level: the level's size is not shown, the bound stands. + assert QUEUE.advance(position, Side.BUY, shown("100.00", "5")).position.ahead == D("300") + # An unknown queue takes the size shown at its level as its bound. + unknown = QueuePosition(LIMIT, None) + assert QUEUE.advance(unknown, Side.BUY, shown("99.99", "70")).position.ahead == D("70") + + +def test_a_queue_book_round_trips_and_is_held_to_its_model() -> None: + book = ( + QueueBook(QUEUE) + .placed("a", QueuePosition(LIMIT, D("12.5"))) + .placed("b", QueuePosition(D("101"), None)) + ) + payload = json.loads(json.dumps(capture_queue(book))) + assert payload["schema_version"] == QUEUE_BOOK_SCHEMA + assert queue_from_primitives(payload, PriceTimeQueue()) == book + with pytest.raises(QueueModelError, match="was supplied"): + queue_from_primitives(payload, PriceTimeQueue(count_unsigned_trades=True)) + with pytest.raises(QueueModelError, match="schema"): + queue_from_primitives({**payload, "schema_version": 99}, QUEUE) + assert book.keeping(["b"]).positions == {"b": QueuePosition(D("101"), None)} + + +# --------------------------------------------------------------------------- # +# The model on the historical drivers +# --------------------------------------------------------------------------- # + + +def test_a_resting_buy_fills_only_what_trades_beyond_the_queue_ahead_of_it() -> None: + plain = run(model=None) + # Without a queue the first print at the limit fills the whole order. + assert fills(plain) == [(1.0, D("100"), LIMIT, "MAKER")] + + queued = run() + assert isinstance(queued, QueuedResult) + assert fills(queued) == [ + (2.0, D("20"), LIMIT, "MAKER"), # 200 printed, 180 of it the queue's + (3.0, D("50"), LIMIT, "MAKER"), # at the front: the whole print + (4.0, D("30"), LIMIT, "MAKER"), # the offer touches the limit + ] + # One OMS order, filled in three pieces through the canonical path. + order = only_order(queued) + assert order.status is OrderStatus.FILLED and order.filled_quantity == D("100") + position = queued.state.portfolio.positions[ACME] + assert position.quantity == D("100") and position.average_cost == LIMIT + assert sum(f.quantity for f in queued.fills) == D("100") + assert not queued.state.allocation.reservations + assert not queued.queue.positions # nothing left resting + + +#: 120 then 130 at the limit: 250 of the 300 ahead, so the order is not reached. +SHORT_OF_THE_FRONT = (*DEPLETION[:2], tick(2, "99.99", "130", SELLER)) + + +def test_a_queue_not_yet_cleared_fills_nothing_and_the_order_rests() -> None: + queued = run(SHORT_OF_THE_FRONT) + assert fills(queued) == [] + order = only_order(queued) + assert order.status is OrderStatus.ACCEPTED and order.is_open + assert queued.queue.positions == {str(order.order_id.value): QueuePosition(LIMIT, D("50"))} + assert fills(run(SHORT_OF_THE_FRONT, model=None)) + + +def test_a_bar_touching_the_limit_does_not_fill_an_unknown_queue_and_one_through_it_does() -> None: + bars = ( + bar(0, "100", "100.5", "99.995", "100.2"), # placed here: the queue is unknown + bar(60, "100.2", "100.4", "99.99", "100.1"), # touches 99.99 + bar(120, "100.1", "100.3", "99.50", "99.8"), # trades through it + ) + assert fills(run(bars, model=None)) == [(60.0, D("100"), LIMIT, "MAKER")] + assert fills(run(bars)) == [(120.0, D("100"), LIMIT, "MAKER")] + # A bar opening beyond the limit fills at its better open, as without a queue. + gapped = (*bars[:1], bar(60, "99.50", "99.7", "99.40", "99.6")) + assert fills(run(gapped)) == [(60.0, D("100"), D("99.50"), "MAKER")] + + +def test_the_fill_policy_still_decides_within_what_the_queue_allows() -> None: + capped = run(fill_policy=LiquidityCappedFill(D("0.5"))) + # Each print offers the policy half its size, held to the queue's release: + # 100 printed -> 50 allowed, 20 released at t2; 25 of the 50 at t3; then + # half the 40 shown on the offer at t4. + assert [(t, q) for t, q, _, _ in fills(capped)] == [ + (2.0, D("20")), + (3.0, D("25")), + (4.0, D("20")), + ] + + +def test_a_whole_unit_run_floors_what_the_queue_releases() -> None: + records = ( + quote(0, "99.99", "300", "100.01", "300"), + tick(1, "99.99", "320.5", SELLER), + tick(2, "99.99", "0.7", SELLER), + ) + queued = run(records, whole_units=True) + assert fills(queued) == [(1.0, D("20"), LIMIT, "MAKER")] + # 0.7 at the front floors to nothing: no fill, the order rests on. + assert only_order(queued).remaining_quantity == D("80") + + +def test_an_expired_order_leaves_the_queue_book_and_never_fills() -> None: + expiring = Poster(OrderTerms.limit(LIMIT, TimeInForce.GTD, expire_at=T0 + 2)) + queued = run(poster=expiring) + order = only_order(queued) + assert order.status is OrderStatus.EXPIRED and fills(queued) == [] + assert not queued.queue.positions + + +def test_the_fill_policy_is_not_asked_about_an_order_the_queue_has_not_reached() -> None: + # A venue that expires whatever it is asked to fill: without a queue the + # first print at the limit reaches the order and the venue expires it; with + # one, the order is behind 300 and nothing is asked of the venue until a + # print reaches it, at t2. + expiring = StaticFill(FillStatus.EXPIRED) + plain = only_order(run(model=None, fill_policy=expiring)) + queued = only_order(run(fill_policy=expiring)) + assert plain.status is OrderStatus.EXPIRED and queued.status is OrderStatus.EXPIRED + assert plain.updated_at == T0 + 1 and queued.updated_at == T0 + 2 + + +def test_a_paused_replay_holds_only_the_orders_still_resting() -> None: + paused = ReplayBacktest.run( + config(), + dataset(DEPLETION), + strategies(Poster(OrderTerms.limit(LIMIT, TimeInForce.GTD, expire_at=T0 + 2))), + context_factory, + queue=QUEUE, + pause_when=lambda progress: progress.records_replayed == 4, + ) + assert isinstance(paused, PausedReplay) and paused.queue is not None + order = paused.run.pipeline.oms.orders.orders()[0] + assert order.status is OrderStatus.EXPIRED + assert not paused.queue.positions and not _payload(paused)["queue"]["positions"] + + +def test_each_step_leaves_only_working_orders_in_the_book() -> None: + """The book a step hands back holds no order that ended at its turn there.""" + + expiring = Poster(OrderTerms.limit(LIMIT, TimeInForce.GTD, expire_at=T0 + 2)) + book = QueueBook(QUEUE) + held: list[int] = [] + cfg = config() + with id_scope(cfg.seed): + state = initialize(cfg, strategies(expiring)) + for record in dataset(DEPLETION).records: + state, result = RunEngine.advance(state, record, context_factory, queue=book) + assert result is not None and result.queue is not None + book = result.queue + held.append(len(book.positions)) + assert held == [1, 1, 0, 0, 0] # joined at t0, expired at its turn at t2 + + +def test_an_order_a_split_cancels_leaves_the_book_a_paused_replay_keeps() -> None: + # A split between t1 and t2 cancels the resting order outside any turn of + # its own; the book carried by a pause after it holds nothing. + split = RunSchedule.of(splits=[ScheduledSplit(Split(ACME, D("2")), T0 + 1.5)]) + paused = ReplayBacktest.run( + config(), + dataset(DEPLETION), + strategies(), + context_factory, + schedule=split, + queue=QUEUE, + pause_when=lambda progress: progress.records_replayed == 3, + ) + assert isinstance(paused, PausedReplay) and paused.queue is not None + assert paused.run.pipeline.oms.orders.orders()[0].status is OrderStatus.CANCELLED + assert not paused.queue.positions + + +def test_a_stop_limit_joins_its_queue_when_it_is_triggered() -> None: + records = ( + quote(0, "99.98", "300", "100.01", "300"), # placed: untriggered, not queued + quote(1, "99.99", "300", "100.03", "300"), # triggered; rests at 99.99 behind 300 + tick(2, "99.99", "120", SELLER), # 180 ahead + quote(3, "99.99", "400", "100.03", "300"), # more shown: arrivals behind it + tick(4, "99.99", "200", SELLER), # 20 beyond the queue + ) + stop_limit = OrderTerms.stop_limit(D("100.02"), LIMIT) + plain = run(records, model=None, poster=Poster(stop_limit)) + assert fills(plain)[0][0] == 2.0 + queued = run(records, poster=Poster(stop_limit)) + assert fills(queued) == [(4.0, D("20"), LIMIT, "MAKER")] + + +@pytest.mark.parametrize( + "terms", + [MARKET, OrderTerms.limit(D("100.01"), TimeInForce.IOC), OrderTerms.limit(D("100.10"))], + ids=["market", "immediate-or-cancel", "marketable-limit"], +) +def test_an_order_that_takes_the_market_is_not_governed(terms: OrderTerms) -> None: + poster = Poster(terms) + plain = run(poster=poster, model=None) + queued = run(poster=Poster(terms)) + assert fills(queued) == fills(plain) and fills(plain) + + +def test_an_order_placed_at_another_assets_event_starts_from_an_unknown_queue() -> None: + other = equity("OTHER") + records = ( + quote(-1, "99.99", "300", "100.01", "300"), + Quote(other.asset_id, T0, D("10"), D("10.02"), D("5"), D("5"), "SIM", "USD"), + tick(1, "99.99", "120", SELLER), # unknown queue: a print at the limit is not counted + quote(2, "99.99", "180", "100.01", "300"), # the level now bounds it at 180 + tick(3, "99.99", "200", SELLER), + ) + base = config() + two = replace( + base, pipeline=replace(base.pipeline, instruments=registry_of(ACME_RECORD, other)) + ) + queued = BacktestEngine.run( + two, dataset(records), strategies(Poster()), context_factory, queue=QUEUE + ) + assert fills(queued) == [(3.0, D("20"), LIMIT, "MAKER")] + plain = BacktestEngine.run(two, dataset(records), strategies(Poster()), context_factory) + assert fills(plain) == [(1.0, D("100"), LIMIT, "MAKER")] + + +def test_the_replay_equals_the_backtest_under_a_queue() -> None: + backtest = run() + replayed = ReplayBacktest.run( + config(), dataset(DEPLETION), strategies(), context_factory, queue=QUEUE + ) + assert isinstance(replayed, ReplayResult) + assert replayed.queue == backtest.queue + assert fills(replayed.backtest) == fills(backtest) + assert replayed.backtest.valuation == backtest.valuation + + +def _replay(pause: int | None = None) -> ReplayResult | PausedReplay: + return ReplayBacktest.run( + config(), + dataset(DEPLETION), + strategies(), + context_factory, + queue=QUEUE, + pause_when=lambda progress: pause is not None and progress.records_replayed == pause, + ) + + +def _objects(paused: PausedReplay) -> RunObjects: + pipeline = paused.run.config.pipeline + return RunObjects( + RuntimeObjects( + pipeline.sizing_model, pipeline.simulator, {"S": Poster()}, pipeline.instruments + ), + paused.run.config.fill_policy, + ) + + +def _payload(paused: PausedReplay) -> dict[str, Any]: + return dict(deserialize(serialize(capture_replay(paused)))) + + +def test_a_replay_paused_anywhere_resumes_to_the_same_queue_and_result() -> None: + whole = _replay() + assert isinstance(whole, ReplayResult) + for pause in range(1, len(DEPLETION)): + paused = _replay(pause) + assert isinstance(paused, PausedReplay) and paused.queue is not None + payload = _payload(paused) + assert payload["queue"]["model"] == "PriceTimeQueue(count_unsigned_trades=False)" + restored = restore_replay( + replay_from_primitives(payload), _objects(paused), dataset(DEPLETION), queue=QUEUE + ) + assert restored.queue == paused.queue + resumed = ReplayBacktest.resume(restored, context_factory) + assert digest_run(resumed.backtest).result_id == digest_run(whole.backtest).result_id + assert resumed.queue == whole.queue + assert fills(resumed.backtest) == fills(whole.backtest) + + +def test_a_paused_replay_is_held_to_its_queue_model() -> None: + paused = _replay(2) + assert isinstance(paused, PausedReplay) + snapshot = replay_from_primitives(_payload(paused)) + objects = _objects(paused) + with pytest.raises(StateDecodeError, match="none was supplied"): + restore_replay(snapshot, objects, dataset(DEPLETION)) + with pytest.raises(StateDecodeError, match="queue book cannot be continued"): + restore_replay( + snapshot, objects, dataset(DEPLETION), queue=PriceTimeQueue(count_unsigned_trades=True) + ) + plain = ReplayBacktest.run( + config(), + dataset(DEPLETION), + strategies(), + context_factory, + pause_when=lambda progress: progress.records_replayed == 2, + ) + assert isinstance(plain, PausedReplay) and plain.queue is None + with pytest.raises(StateDecodeError, match="no queue model"): + restore_replay( + replay_from_primitives(_payload(plain)), objects, dataset(DEPLETION), queue=QUEUE + ) + + +def test_a_queue_model_is_refused_where_nothing_is_simulated() -> None: + plan = ExecutionPlan(AlgorithmAssignment(TWAP(2, Urgency.neutral()), 600.0, D("1"))) + with pytest.raises(QueueModelError, match="execution plan's children"): + BacktestEngine.run( + config(), dataset(DEPLETION), strategies(), context_factory, execution=plan, queue=QUEUE + ) + # A paper run simulates its fills, so a queue may govern them; a live run's + # orders are its venue's. + live = initialize(replace(config(), mode=ExecutionMode.LIVE), strategies()) + with pytest.raises(RuntimeValidationError, match="routes EXTERNAL"): + RunEngine.advance( + live, dataset(DEPLETION).records[0], context_factory, queue=QueueBook(QUEUE) + ) + + +def test_a_run_is_recorded_as_it_was_and_its_result_names_the_model() -> None: + market = Poster(MARKET) + plain = run(poster=market, model=None) + queued = run(poster=Poster(MARKET)) + # The queue book is driver state: the run's record and identities do not move. + assert digest_run(queued) == digest_run(plain) + assert type(plain) is not QueuedResult + + assumptions = queued.execution_assumptions + assert assumptions.queue_model == "PriceTimeQueue(count_unsigned_trades=False)" + assert "queue model" in assumptions.passive_fills and "front" in assumptions.passive_fills + assert assumptions.settings()["execution.queue_model"] == assumptions.queue_model + assert assumptions.optimistic == plain.execution_assumptions.optimistic + + without = plain.execution_assumptions + assert without.queue_model is None and "execution.queue_model" not in without.settings() + assert "optimistic for passive orders" in without.passive_fills diff --git a/tests/regression/test_currency_authority.py b/tests/regression/test_currency_authority.py index cdcd60f5..891d8da9 100644 --- a/tests/regression/test_currency_authority.py +++ b/tests/regression/test_currency_authority.py @@ -633,13 +633,19 @@ def test_settlement_refusals_are_not_persisted() -> None: def test_the_result_field_is_appended_last() -> None: - """So positional construction of an ``ExecutionPipelineResult`` keeps working.""" + """So positional construction of an ``ExecutionPipelineResult`` keeps working. + + ``settlement_refusals`` was appended last (ADR-0028); the one field added + after it since, ``queue`` (v4.1.1, ledger FUT-011), was appended after it in + turn, with a default, so every positional construction written before it + still means what it meant. + """ from dataclasses import fields names = [f.name for f in fields(ExecutionPipelineResult)] - assert names[-1] == "settlement_refusals" + assert names[-2:] == ["settlement_refusals", "queue"] assert names.index("unpriced_requests") < names.index("valuation") diff --git a/tests/regression/test_documented_schemas_are_current.py b/tests/regression/test_documented_schemas_are_current.py index bed3016e..ea825e20 100644 --- a/tests/regression/test_documented_schemas_are_current.py +++ b/tests/regression/test_documented_schemas_are_current.py @@ -41,6 +41,9 @@ # v4.1.1 (ledger FUT-009): an execution plan's algorithm book, carried inside # a paused replay's payload rather than as a durable state of its own. "ALGORITHM_BOOK_SCHEMA": "alphalab.backtesting.algorithmic", + # v4.1.1 (ledger FUT-011): a queue model's book, carried inside a paused + # replay's payload on the same terms. + "QUEUE_BOOK_SCHEMA": "alphalab.backtesting.queueing", } #: The eleven durable states' constants: every table must list each of them. diff --git a/tests/regression/test_every_adr_deferral_is_classified.py b/tests/regression/test_every_adr_deferral_is_classified.py index 4bb1cea5..6df2ff1e 100644 --- a/tests/regression/test_every_adr_deferral_is_classified.py +++ b/tests/regression/test_every_adr_deferral_is_classified.py @@ -139,7 +139,9 @@ ("0051", "Participation that completes after its window is refused"): "FUT-009", ("0051", "A child is checked against the venue's declaration, not by risk again"): "FUT-009", ("0051", "An algorithm is assigned per asset, not per strategy"): "FUT-009", - ("0051", "a queue-position and fill-probability model"): "FUT-011", + # FUT-011 delivered a queue model in the candidate; ADR-0051 now states what + # it does not model, which the ledger entry states too. + ("0051", "The queue model reads the displayed top of the book"): "FUT-011", ("0051", "A strategy cannot instruct an exercise from its hooks"): "FUT-014", ("0051", "Cash and deliverables move at the event's instant"): "FUT-015", ("0051", "One deliverable, in the consideration's currency, American or European"): "FUT-016", diff --git a/tests/regression/test_replay_pause_and_resume.py b/tests/regression/test_replay_pause_and_resume.py index 306a2742..890cfb3a 100644 --- a/tests/regression/test_replay_pause_and_resume.py +++ b/tests/regression/test_replay_pause_and_resume.py @@ -914,12 +914,19 @@ def test_the_frozen_v4_1_0_payload_reads_and_resumes() -> None: def test_the_frozen_payload_is_what_this_build_writes() -> None: - """Byte for byte, but for the one step schema 2 declares (v4.1.1, ledgers FUT-003 and - FUT-009): the version, the digest of a schedule and the algorithm book of an - execution plan, neither of which a v4.1.0 replay could have had.""" + """Byte for byte, but for the one step schema 2 declares (v4.1.1, ledgers FUT-003, + FUT-009 and FUT-011): the version, the digest of a schedule, the algorithm book of + an execution plan and the queue book of a queue model, none of which a v4.1.0 + replay could have had.""" frozen = json.loads(FIXTURE.read_text(encoding="utf-8")) - upgraded = {**frozen, "schema_version": 2, "schedule_digest": None, "algorithms": None} + upgraded = { + **frozen, + "schema_version": 2, + "schedule_digest": None, + "algorithms": None, + "queue": None, + } assert upgraded == _payload(_paused(11)) @@ -944,6 +951,10 @@ def test_the_snapshot_covers_every_cursor_field() -> None: # (ledger FUT-009; test_algorithms_on_the_historical_path.py # ::test_a_replay_paused_with_children_working_resumes_from_its_payload). "algorithms", + # v4.1.1: carried whole (schema 2), the queue model supplied back and held + # to it (ledger FUT-011; test_queue_model.py + # ::test_a_replay_paused_anywhere_resumes_to_the_same_queue_and_result). + "queue", } diff --git a/tests/regression/test_v37_invariants.py b/tests/regression/test_v37_invariants.py index 8736b383..0daac6b5 100644 --- a/tests/regression/test_v37_invariants.py +++ b/tests/regression/test_v37_invariants.py @@ -775,7 +775,7 @@ def test_v37_added_no_snapshot_owner_and_no_schema_constant() -> None: # added one more, as deliberately: a paused replay's envelope around a run # snapshot (ledger FUT-001). v4.1.1 added an execution plan's algorithm book, # carried in a paused replay and stored beside a continued backtest's run - # (ledger FUT-009). + # (ledger FUT-009), and a queue model's book on the same terms (ledger FUT-011). assert constants == [ "ALGORITHM_BOOK_SCHEMA", "ALLOCATION_SNAPSHOT_SCHEMA", @@ -789,6 +789,7 @@ def test_v37_added_no_snapshot_owner_and_no_schema_constant() -> None: "OMS_SNAPSHOT_SCHEMA", "PIPELINE_SNAPSHOT_SCHEMA", "PORTFOLIO_SNAPSHOT_SCHEMA", + "QUEUE_BOOK_SCHEMA", "REPLAY_SNAPSHOT_SCHEMA", "RUN_SNAPSHOT_SCHEMA", "RUN_STATE_ENVELOPE_SCHEMA", diff --git a/tests/regression/test_v38_invariants.py b/tests/regression/test_v38_invariants.py index 5c38f1df..342989ed 100644 --- a/tests/regression/test_v38_invariants.py +++ b/tests/regression/test_v38_invariants.py @@ -1109,7 +1109,7 @@ def test_v38_added_no_snapshot_owner_and_no_schema_constant() -> None: # added one more, as deliberately: a paused replay's envelope around a run # snapshot (ledger FUT-001). v4.1.1 added an execution plan's algorithm book, # carried in a paused replay and stored beside a continued backtest's run - # (ledger FUT-009). + # (ledger FUT-009), and a queue model's book on the same terms (ledger FUT-011). assert constants == [ "ALGORITHM_BOOK_SCHEMA", "ALLOCATION_SNAPSHOT_SCHEMA", @@ -1123,6 +1123,7 @@ def test_v38_added_no_snapshot_owner_and_no_schema_constant() -> None: "OMS_SNAPSHOT_SCHEMA", "PIPELINE_SNAPSHOT_SCHEMA", "PORTFOLIO_SNAPSHOT_SCHEMA", + "QUEUE_BOOK_SCHEMA", "REPLAY_SNAPSHOT_SCHEMA", "RUN_SNAPSHOT_SCHEMA", "RUN_STATE_ENVELOPE_SCHEMA", diff --git a/tests/unit/lifecycle/test_reproducibility.py b/tests/unit/lifecycle/test_reproducibility.py index 5bbb9c48..ce52b09d 100644 --- a/tests/unit/lifecycle/test_reproducibility.py +++ b/tests/unit/lifecycle/test_reproducibility.py @@ -18,7 +18,13 @@ import pytest from alphalab.api import backtest -from alphalab.backtesting import AlgorithmAssignment, AlgorithmicResult, ExecutionPlan +from alphalab.backtesting import ( + AlgorithmAssignment, + AlgorithmicResult, + ExecutionPlan, + PriceTimeQueue, + QueuedResult, +) from alphalab.data.dataset import Dataset from alphalab.data.exceptions import DataValidationError from alphalab.data.time import BarStamp @@ -626,6 +632,51 @@ def test_a_manifest_names_the_plan_that_worked_the_runs_orders(dataset: Dataset) ) +# --------------------------------------------------------------------------- # +# A run whose resting limit orders a queue model governed (v4.1.1, FUT-011) +# --------------------------------------------------------------------------- # + + +def test_a_manifest_names_the_queue_model_its_run_was_measured_under(dataset: Dataset) -> None: + queued = backtest( + run_config(STRATEGY_ID, SEED), + dataset, + running_strategy_state(STRATEGY_ID, BarStrategy(STRATEGY_ID, ASSET_ID, PLAN)), + context_factory, + NORMALIZATION, + queue=PriceTimeQueue(), + ) + assert isinstance(queued, QueuedResult) + plain_result = run_backtest(dataset) + manifest = manifest_for_run(queued, dataset, fingerprint(), ENGINE, build=BUILD) + plain = manifest_for_run(plain_result, dataset, fingerprint(), ENGINE, build=BUILD) + + # The queue book is driver state: the run's record and configuration are the + # plain run's. The manifest names the model, so the two are not one manifest. + assert manifest.result_id == plain.result_id + assert manifest.configuration_id == plain.configuration_id + assert manifest.queue_model == "PriceTimeQueue(count_unsigned_trades=False)" + assert plain.queue_model is None + assert manifest.manifest_id != plain.manifest_id + assert verify_manifest(manifest) and verify_manifest(plain) + assert not verify_manifest(replace(manifest, queue_model=None)) + assert any( + "queue model" in requirement.detail for requirement in external_requirements(manifest) + ) + assert "inputs.queue_model" not in canonical_manifest_key( + plain.kind, + plain.result_id, + plain.dataset_version, + plain.dataset_content_hash, + plain.configuration_id, + plain.seed, + plain.seed_role, + plain.engine, + plain.fingerprint.fingerprint if plain.fingerprint else None, + plain.build, + ) + + def test_a_manifest_refuses_a_plan_the_run_was_not_worked_by(dataset: Dataset) -> None: result = _algorithmic(dataset) with pytest.raises(LifecycleInputError, match="no execution plan was given"): From b553a4e859e1eb6bb19f0eb1147f9c320e5b30c9 Mon Sep 17 00:00:00 2001 From: VarunSingh022 Date: Mon, 26 Oct 2026 19:18:48 +0530 Subject: [PATCH 03/56] feat(strategy): a strategy configuration read back as its declared type (FUT-002) A strategy class that sets configuration_type gets three guarantees: - It is configured only with a value of that type. - After a restore, it reads the recorded value back as that type, equal and of the same types at every depth, so restore(capture(s)) == s. - The payload is exactly what the encoder has always written. No schema, result_id, configuration_id or manifest moves, and every earlier payload reads as before. What it adds - alphalab.strategy.configuration, also exported by alphalab.strategy: - CONFIGURATION_ATTRIBUTE; - StrategyConfigurationError; - declared_configuration; - require_configuration; - decode_configuration. - RuntimeSupervisor.configure checks a declaring strategy's value against its type, strictly. An int is not a float, a bool is not an int, a str subclass is not a str, and a non-finite float or Decimal is refused. Each refusal names the field. - restore reads a declaring strategy's configuration back as its type. Anything that does not match is refused as a StateDecodeError naming the strategy and the field. Declarable - Scalars: None, bool, int, finite float, str, finite Decimal (its exact text, exponent kept) and UUID. - Enums: StrEnum, IntEnum and plain Enum. - Literal, T | None and Annotated. - list[T], tuple[T, ...] and fixed tuples. - dict[K, V] and Mapping[K, V], the latter read back as a read-only mapping. K may be str, int, bool, float, or a string or integer enum. - Dataclasses, rebuilt through their own constructors. A field missing from an older payload reads back at its default, and __post_init__ constraints hold on the way back. - Any, for JSON-native values. Refused when the strategy is configured, never coerced - A set or a datetime. - A union of two non-None types. - A mapping keyed by Decimal, UUID or a plain Enum. - A class that is not a dataclass, or a dataclass with a non-init field. - An enum that redefines how it prints. Scope, stated on the ledger, the ROADMAP and ADR-0051 decision 16 - The typed read-back comes from the declaration. - A strategy that declares nothing reads back as JSON reads it (LIM-004), unchanged. - Reading an undeclared configuration typed would need type tags in the payload. That would move every run's identity, which this release does not do. Docs and tests - Docs: the ledger (FUT-002 delivered), ROADMAP (FUT-002 moved out of Future work), CHANGELOG [Unreleased], ADR-0051, STATE_MODEL, INTEGRATION, the capability matrix, the gaps report, the readiness report, PUBLIC_API and the public API manifest (2,683 exports). The certificate re-certified with only the source digest and the API manifest's digest and export count moved. - Tests: tests/regression/test_typed_strategy_configuration.py (62). The LIM-004 contract suite is unchanged and passes; its docstring now names the typed path. The ADR-deferral classifier lists the new limitation. Validation (local; this commit's hosted CI is not yet run) - ruff and format are clean (1,315 files); mypy has no issues in 1,213 source files. - Dev checkout: 10,115 passed, 3 failed. - Fresh CPython 3.12.4, 3.13.17 and 3.14.9: 10,116 passed, 2 failed each. - All 75 examples pass. - certify_release --check: 11 checks passed. - Build, twine --strict and preflight passed (612 modules, 2,683 names). - Mutation checks: 13 of 13 caught. - The failures are the two version-keyed guards, plus the dev venv's stale editable metadata. The version is unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 24 +- ROADMAP.md | 31 +- alphalab/runtime/snapshot.py | 28 +- alphalab/strategy/__init__.py | 12 + alphalab/strategy/configuration.py | 501 ++++++++++++++++++ alphalab/strategy/supervisor.py | 18 + ...river-and-the-strategy-capability-audit.md | 28 +- docs/INTEGRATION.md | 14 +- docs/STATE_MODEL.md | 7 +- docs/api/PUBLIC_API.md | 2 +- docs/api/public_api.json | 5 + docs/audit/FUTURE_READINESS_V4_1_1.md | 44 +- docs/audit/PRE_V4_COMPLETION_LEDGER.yaml | 29 +- docs/audit/RELEASE_CERTIFICATION.md | 6 +- docs/audit/STRATEGY_CAPABILITY_MATRIX.md | 2 +- .../STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md | 3 +- docs/audit/release_certification.json | 6 +- .../test_every_adr_deferral_is_classified.py | 1 + .../test_nothing_is_left_for_later.py | 5 +- .../test_strategy_configuration_contract.py | 4 + .../test_typed_strategy_configuration.py | 414 +++++++++++++++ 21 files changed, 1104 insertions(+), 80 deletions(-) create mode 100644 alphalab/strategy/configuration.py create mode 100644 tests/regression/test_typed_strategy_configuration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 02488fc9..4f8b3c06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,8 @@ driver, contracts that expire, are exercised, assigned and delivered by their declared terms in any market a calendar describes, bonds that pay coupons and redeem, orders worked in children by execution algorithms over historical bars, resting limit orders that fill only once a declared queue model puts them at the -front of their price level, a paused replay that keeps all of it durably -- and a +front of their price level, a strategy configuration read back as its declared +type, a paused replay that keeps all of it durably -- and a source-derived audit of what AlphaLab supports from research to production.** @@ -129,6 +130,20 @@ IDs are in brackets. `QUEUE_BOOK_SCHEMA = 1`; `replay_snapshot.restore(..., queue=)`), and `manifest_for_run` records the model as `ReproducibilityManifest.queue_model`. Refused beside an execution plan and on a run that routes to a venue. +* **A strategy configuration read back as its declared type** [FUT-002]. + `alphalab.strategy.configuration`: a strategy class that sets + `configuration_type` -- a dataclass, say, with `Decimal`, tuple, enum, `UUID` + and typed-mapping fields -- is configured only with a value of that type + (`RuntimeSupervisor.configure` raises `StrategyConfigurationError`, naming the + field), and a restore reads the recorded value back as that type, equal and of + the same types at every depth: dataclasses through their own constructors, so + defaults fill a field an older payload lacks and `__post_init__` constraints + hold. A type whose values cannot be read back -- a set, a `datetime`, a union + of two non-`None` types, a mapping keyed by `Decimal` -- is refused at + configure; a recorded value not of the declared type is refused at restore + (`StateDecodeError`). The payload is what the encoder always wrote, so no + schema, `result_id`, `configuration_id` or manifest moves; a strategy that + declares nothing reads back as JSON reads it (LIM-004), as before. * **The macro engine's series as observations** [FUT-008]. `alphalab.api.observations_from_indicator`. * **`LiveSession.deliver_observation`** [OFE-027]. @@ -167,13 +182,14 @@ IDs are in brackets. | `alphalab.backtesting.BacktestEngine.run` | keyword-only `schedule`, `execution` and `queue` | | `alphalab.backtesting.replay_snapshot.restore` | `schedule`, empty by default, and `execution` and `queue`, `None` by default; `ReplaySnapshot.schedule_digest`, `algorithms` and `queue` | | `alphalab.runtime.RunEngine.advance`, `ExecutionPipeline.process_record`, `process_market_event` | keyword-only `queue`, `None` by default; `ExecutionPipelineResult.queue`, appended last, `None` by default | +| `alphalab.strategy.supervisor.RuntimeSupervisor.configure` | raises `StrategyConfigurationError` for a value not of the type its strategy's class declares as `configuration_type`; unchanged for a class that declares none | | `alphalab.runtime.ExecutionAssumptions`, `execution_assumptions` | field `queue_model` and trailing parameter `queue_model`, `None` by default; property `passive_fills`; `settings()` adds `execution.queue_model` only when a model is declared | | `alphalab.runtime.execution_pipeline.ExecutionRouting` | member `ALGORITHMIC`; `ExecutionMode.works_children` | | `alphalab.core.enums.AssetType` | members `BOND` and `INDEX` | | `alphalab.instrument.record.InstrumentRecord` | fields `last_trade_at` and `terms`, `None` by default; `InstrumentRegistry.expiring`, a derived index | | `alphalab.lifecycle.manifest_for_run`, `canonical_manifest_key`, `derive_manifest_id` | keyword or trailing `schedule` / `schedule_digest` (`None`) and `dataset_parts` (empty) by default, `execution` / `execution_plan_id` and `queue_model` (`None`); `ReproducibilityManifest.schedule_digest`, `dataset_parts`, `execution_plan_id` and `queue_model` | | `alphalab.portfolio.engine.PortfolioEngine.apply_fill` | keyword-only `final_settlement`, `False` by default | -| `alphalab.execution.queue` (`PriceTimeQueue`, `QueueBook`, `QueueModel`, `QueueObservation`, `QueuePosition`, `QueueTurn`) and `alphalab.backtesting.queueing` (`QUEUE_BOOK_SCHEMA`, `QueueModelError`, `capture_queue`, `queue_from_primitives`, `queued_result`, `queued_run`); `alphalab.backtesting.QueuedResult`; `alphalab.backtesting.schedule` (with `ScheduledExercise`, `ScheduledAssignment`, `ScheduledExpiry` and `RunSchedule.of(exercises=, assignments=, expiries=)`), `alphalab.backtesting.algorithmic` and `alphalab.instrument.terms` and their names; `alphalab.portfolio.Exercise`, `Assignment`, `OptionExpiry`; `RunEngine.exercise`, `assign`, `expire_option`; `ExecutionPipeline.exercise`, `assign`, `expire_option`; `AllocationEngine.shares_of`, `apply_lifecycle`; `alphalab.runtime.session_close`, `settles_at`; `AlgorithmicResult`, `ExecutionPipeline.work_child`, `ChildTurn`; `alphalab.portfolio.ContractSettlement`; `alphalab.api.CombinedDataset`, `CombinedProvenance`, `CombinedSourceBytes`, `combine_datasets`, `combined_market_dataset`, `observations_from_indicator`, `bond_schedule`; `RunEngine.fund`, `RunEngine.settle_contract`, `ExecutionPipeline.settle_contract`, `AllocationEngine.settle_asset`, `LiveSession.deliver_observation`, `held_past_expiry` | added | +| `alphalab.strategy.configuration` (`CONFIGURATION_ATTRIBUTE`, `StrategyConfigurationError`, `declared_configuration`, `decode_configuration`, `require_configuration`), also exported by `alphalab.strategy`; `alphalab.execution.queue` (`PriceTimeQueue`, `QueueBook`, `QueueModel`, `QueueObservation`, `QueuePosition`, `QueueTurn`) and `alphalab.backtesting.queueing` (`QUEUE_BOOK_SCHEMA`, `QueueModelError`, `capture_queue`, `queue_from_primitives`, `queued_result`, `queued_run`); `alphalab.backtesting.QueuedResult`; `alphalab.backtesting.schedule` (with `ScheduledExercise`, `ScheduledAssignment`, `ScheduledExpiry` and `RunSchedule.of(exercises=, assignments=, expiries=)`), `alphalab.backtesting.algorithmic` and `alphalab.instrument.terms` and their names; `alphalab.portfolio.Exercise`, `Assignment`, `OptionExpiry`; `RunEngine.exercise`, `assign`, `expire_option`; `ExecutionPipeline.exercise`, `assign`, `expire_option`; `AllocationEngine.shares_of`, `apply_lifecycle`; `alphalab.runtime.session_close`, `settles_at`; `AlgorithmicResult`, `ExecutionPipeline.work_child`, `ChildTurn`; `alphalab.portfolio.ContractSettlement`; `alphalab.api.CombinedDataset`, `CombinedProvenance`, `CombinedSourceBytes`, `combine_datasets`, `combined_market_dataset`, `observations_from_indicator`, `bond_schedule`; `RunEngine.fund`, `RunEngine.settle_contract`, `ExecutionPipeline.settle_contract`, `AllocationEngine.settle_asset`, `LiveSession.deliver_observation`, `held_past_expiry` | added | ## Accepted as future work @@ -181,7 +197,9 @@ FUT-014 (a strategy-facing exercise instruction), FUT-015 (settlement-date accounting), FUT-016 (richer contract terms) and FUT-017 (margin and forced liquidation), each with its limitation, interim behaviour and acceptance criteria on `ROADMAP.md`. FUT-009, FUT-011 and FUT-013, accepted by the audit and the -continuation, were delivered by the candidate's last three milestones; FUT-011's +continuation, and FUT-002, accepted by v4.1, were delivered by the candidate's +last four milestones -- FUT-002 by declaration, so an undeclared configuration +keeps the stated reading (LIM-004); FUT-011's model is price-time priority over displayed liquidity, and what it does not model -- pro-rata books, hidden liquidity, a fill probability -- is stated on the ledger entry. diff --git a/ROADMAP.md b/ROADMAP.md index ff2b8c58..d67056fe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -198,6 +198,13 @@ names what the release established and the ADR that records the decision. run's manifest, kept by a paused replay. Price-time priority over displayed liquidity only: pro-rata books, hidden liquidity and a fill probability are other models of the same protocol. +- **A strategy configuration that reads back typed** (FUT-002): a strategy class + declaring `configuration_type` (`alphalab.strategy.configuration`) is configured + only with a value of that type and reads it back as that type after a restore + -- `Decimal`, tuples, enums, `UUID`, typed mapping keys, dataclasses rebuilt + through their constructors with their defaults and constraints -- with the + payload, and so every run's identity, unchanged. An undeclared configuration + reads back as JSON reads it (LIM-004), as before. # Deliberate boundaries @@ -513,7 +520,9 @@ Built, with a stated limit. Each has a ledger entry that gives its reason. v4.1 states that as a tested contract (`docs/STATE_MODEL.md`). Nothing on the execution path reads the value, and a run's identities are the same before and after a restore; durable strategy state goes through `StrategyStateProtocol`, - which round-trips exactly. A lossless typed form is FUT-002, below. + which round-trips exactly. Since the v4.2.0 candidate a strategy that declares + its configuration's type reads it back typed (FUT-002, above); one that + declares nothing reads back as stated here. - **Transcendental functions in the standalone engines** (LIM-005): since v4.1 a run's record -- and so its `result_id` -- is host-independent: money is `Decimal`, and every float a run records comes from `+ - * /`, `sqrt`, `fsum` @@ -542,26 +551,6 @@ Accepted for a later release, each with what it is not yet, what happens in the meantime and what closes it. `tests/regression/test_nothing_is_left_for_later.py` holds this list to the ledger: an entry may be left for later only here. -## FUT-002 — a strategy configuration that reads back typed (target v5.0, pending the maintainer's decision) - -- **Limitation.** `restore(capture(s)) == s` does not hold for a strategy whose - `configure` value holds a `Decimal`, a tuple, a non-string mapping key, an - enum, a `UUID` or a dataclass: each reads back in the converted form - `docs/STATE_MODEL.md` states (LIM-004). -- **Interim behaviour.** The conversion is exact and tested - (`tests/regression/test_strategy_configuration_contract.py`); nothing on the - execution path reads the value; identities are unaffected. Keep durable - strategy parameters in `StrategyStateProtocol` state, which round-trips - exactly, or configure with JSON-native values. -- **Why not in 4.x.** A typed form changes the pipeline payload and so every - run's `result_id`, which `docs/api/PUBLIC_API.md`'s stability policy does not - let a minor release do. Taking it in 5.0, or as a stated 4.x exception, is the - maintainer's decision. -- **Acceptance criteria.** `restore(capture(s)) == s` for every value the - contract converts; every earlier payload read as it reads today; a migration - row for the identity change; the ledger entry closed. -- **Dependencies.** A pipeline schema step, and frozen v4.x payloads. - The entries below were proposed by the v4.1.1 future-readiness audit (`docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md`); accepting each, and its horizon, is the maintainer's decision. The candidate itself delivered the rest diff --git a/alphalab/runtime/snapshot.py b/alphalab/runtime/snapshot.py index cc2af53d..962fc2f9 100644 --- a/alphalab/runtime/snapshot.py +++ b/alphalab/runtime/snapshot.py @@ -210,6 +210,11 @@ _require_one_account_currency, ) from alphalab.runtime.retention import RetentionPolicy +from alphalab.strategy.configuration import ( + StrategyConfigurationError, + declared_configuration, + decode_configuration, +) from alphalab.strategy.events import ( FillEvent, LifecycleTransitioned, @@ -1457,6 +1462,27 @@ def _restore_state(record: StrategyRecord, instance: object) -> None: ) from exc +def _restore_configuration(record: StrategyRecord, instance: object) -> Any: + """The strategy's configuration, as its declared type when it declares one (FUT-002). + + The payload holds what the encoder wrote, as it always has; a strategy whose + class declares ``configuration_type`` reads it back as that type -- see + :mod:`alphalab.strategy.configuration` -- and one that declares nothing + reads it back as JSON reads it (LIM-004). + """ + + declared = declared_configuration(instance) + if declared is None: + return record.config + try: + return decode_configuration(record.config, declared, f"{record.strategy_id} config") + except StrategyConfigurationError as refused: + raise StateDecodeError( + f"The configuration recorded for strategy {record.strategy_id!r} is not of the " + f"type its class declares: {refused}" + ) from refused + + def _restore_strategies( records: tuple[StrategyRecord, ...], events: tuple[StrategyEventRecord, ...], @@ -1476,7 +1502,7 @@ def _restore_strategies( strategy_id=record.strategy_id, status=record.status, instance=instance, - config=record.config, + config=_restore_configuration(record, instance), subscriptions=frozenset(record.subscriptions), last_error=record.last_error, started=record.started, diff --git a/alphalab/strategy/__init__.py b/alphalab/strategy/__init__.py index 034e9c24..aaa372ea 100644 --- a/alphalab/strategy/__init__.py +++ b/alphalab/strategy/__init__.py @@ -43,6 +43,13 @@ TrailingZScoreRule, ) from alphalab.strategy.adaptive_strategy import ADAPTIVE_STRATEGY_STATE_VERSION, AdaptiveStrategy +from alphalab.strategy.configuration import ( + CONFIGURATION_ATTRIBUTE, + StrategyConfigurationError, + declared_configuration, + decode_configuration, + require_configuration, +) from alphalab.strategy.context import ( DiscardingLogger, FixedClock, @@ -117,6 +124,7 @@ "ADAPTIVE_REPLAY_SCHEME", "ADAPTIVE_STATE_SCHEME", "ADAPTIVE_STRATEGY_STATE_VERSION", + "CONFIGURATION_ATTRIBUTE", "SUBSCRIBE_ALL", "AdaptationMode", "AdaptiveConfiguration", @@ -167,6 +175,7 @@ "SliceStrategyProtocol", "StateValue", "StrategyClassRegistry", + "StrategyConfigurationError", "StrategyContext", "StrategyDeclaration", "StrategyDefinition", @@ -194,6 +203,8 @@ "checkpoint", "context_factory", "create_runtime", + "declared_configuration", + "decode_configuration", "defines_on_observation", "defines_on_slice", "failed_strategies", @@ -204,6 +215,7 @@ "observation_stream", "register_strategy", "replay_updates", + "require_configuration", "restore", "runtime_for", "start_strategy", diff --git a/alphalab/strategy/configuration.py b/alphalab/strategy/configuration.py new file mode 100644 index 00000000..9ed20a48 --- /dev/null +++ b/alphalab/strategy/configuration.py @@ -0,0 +1,501 @@ +"""A strategy's configuration, declared by type and read back exactly (ledger FUT-002). + +What a strategy is configured with is recorded on its run's snapshot as the +encoder writes it, and read back as JSON reads it (LIM-004): a ``Decimal`` as +its text, a tuple as a list, an enum by its value or name, a dataclass as a +mapping. That conversion is exact and stated +(``tests/regression/test_strategy_configuration_contract.py``), and it is also +lossy in type -- ``restore(capture(s)) == s`` does not hold for such a value. + +A strategy that declares its configuration's type closes that, without moving +the payload. It sets ``configuration_type`` on its class:: + + @dataclass(frozen=True) + class Bands: + window: int + width: Decimal + sides: tuple[Side, ...] = (Side.BUY, Side.SELL) + + + class MeanReversion(BaseStrategy): + configuration_type = Bands + +Then :meth:`~alphalab.strategy.supervisor.RuntimeSupervisor.configure` refuses a +value that is not of that type -- naming the field and what was found -- and +:func:`~alphalab.runtime.snapshot.restore` reads the recorded value back *as* +that type: the ``Decimal`` from its text, the tuple, the enum member, the +dataclass rebuilt through its own constructor, so its defaults and its +``__post_init__`` constraints hold on the way back too. The payload is what the +encoder has always written, so no schema moves and no run's ``result_id`` or +``configuration_id`` moves; a strategy that declares nothing is read back as +before. + +What may be declared +-------------------- +``None``, ``bool``, ``int``, ``float`` (finite), ``str``, ``Decimal`` (finite), +``UUID``; an ``Enum`` subclass (a ``StrEnum`` or ``IntEnum`` written by value, a +plain ``Enum`` by ``Class.MEMBER``); ``Literal`` of those; ``list[T]``, +``tuple[T, ...]`` and ``tuple[A, B, ...]``; ``dict[K, V]`` and ``Mapping[K, V]`` +with ``K`` one of ``str``, ``int``, ``bool``, ``float`` or a string or integer +enum -- what JSON can key by; ``T | None``; ``Annotated[T, ...]``; a dataclass +whose fields are declared with such types; and ``Any``, for a value that is +JSON-native already (``None``, ``bool``, ``int``, ``str``, a finite ``float``, +and lists and string-keyed mappings of them), which reads back exact without a +type. Anything else -- a ``set``, a ``datetime``, a union of two non-``None`` +types (its JSON form would not say which), a class that is not a dataclass -- is +refused when the strategy is configured, never coerced. + +Checks are strict: an ``int`` is not a ``float``, a ``bool`` not an ``int``, a +``str`` subclass not a ``str``, because each would read back as the other type. +""" + +from __future__ import annotations + +import math +import types +import typing +from collections.abc import Mapping +from dataclasses import MISSING, fields, is_dataclass +from decimal import Decimal, InvalidOperation +from enum import Enum +from typing import Any, Final, Literal, Union, get_args, get_origin +from uuid import UUID + +from alphalab.strategy.exceptions import StrategyValidationError + +__all__ = [ + "CONFIGURATION_ATTRIBUTE", + "StrategyConfigurationError", + "declared_configuration", + "decode_configuration", + "require_configuration", +] + +#: The class attribute a strategy declares its configuration's type by. +CONFIGURATION_ATTRIBUTE: Final = "configuration_type" + +_NONE_TYPE: Final = type(None) +_SCALARS: Final = (bool, int, float, str, Decimal, UUID) +_KEY_SCALARS: Final = (str, int, bool, float) + + +class StrategyConfigurationError(StrategyValidationError): + """A configuration that is not of its declared type, or a type that cannot be declared.""" + + +def declared_configuration(instance: object) -> Any | None: + """The type ``instance``'s class declares its configuration as, or ``None``.""" + + return getattr(type(instance), CONFIGURATION_ATTRIBUTE, None) + + +def require_configuration(value: Any, declared: Any, where: str = "config") -> None: + """Refuse ``value`` unless it is of the ``declared`` type, exactly enough to read back as it. + + Raises: + StrategyConfigurationError: Naming the first field that is not, or the + first part of ``declared`` that cannot be declared. + """ + + _require_declarable(declared, where, set()) + _require(value, declared, where) + + +def decode_configuration(value: Any, declared: Any, where: str = "config") -> Any: + """Read the encoder's form of a configuration back as its ``declared`` type. + + The inverse of what the encoder writes for every value + :func:`require_configuration` admits: ``decode(json(encode(v))) == v``, and + of the same types, for each. + + Raises: + StrategyConfigurationError: If ``value`` is not the encoder's form of a + value of that type, naming the field. + """ + + _require_declarable(declared, where, set()) + return _decode(value, declared, where) + + +# --------------------------------------------------------------------------- # +# The type, on its own +# --------------------------------------------------------------------------- # + + +def _unwrap(declared: Any) -> Any: + """``declared`` without ``Annotated`` metadata.""" + + while get_origin(declared) is typing.Annotated: + declared = get_args(declared)[0] + return declared + + +def _optional(declared: Any) -> Any | None: + """``T`` for ``T | None`` (or ``Optional[T]``), else ``None``.""" + + if get_origin(declared) in (Union, types.UnionType): + members = [member for member in get_args(declared) if member is not _NONE_TYPE] + if len(members) == 1 and len(get_args(declared)) == 2: + return members[0] + return None + + +def _refuse(where: str, message: str) -> StrategyConfigurationError: + return StrategyConfigurationError(f"{where}: {message}") + + +def _enum_spelling(cls: type[Enum]) -> str: + """How the encoder writes a member of ``cls``: ``value`` or ``name``.""" + + if issubclass(cls, str | int): + return "value" + return "name" + + +def _require_declarable(declared: Any, where: str, seen: set[int]) -> None: + declared = _unwrap(declared) + if declared is Any or declared is None or declared is _NONE_TYPE: + return + inner = _optional(declared) + if inner is not None: + _require_declarable(inner, where, seen) + return + origin = get_origin(declared) + if origin in (Union, types.UnionType): + raise _refuse( + where, + f"{declared} is a union of two types besides None; its recorded form would not " + "say which, so it cannot be read back. Declare one type.", + ) + if origin is Literal: + for member in get_args(declared): + if type(member) not in (bool, int, str): + raise _refuse(where, f"Literal member {member!r} is not a bool, int or str.") + return + if origin is list: + (item,) = get_args(declared) or (Any,) + _require_declarable(item, f"{where}[]", seen) + return + if origin is tuple: + args = get_args(declared) + if len(args) == 2 and args[1] is Ellipsis: + _require_declarable(args[0], f"{where}[]", seen) + else: + for index, item in enumerate(args): + _require_declarable(item, f"{where}[{index}]", seen) + return + if origin in (dict, Mapping): + key, item = get_args(declared) or (str, Any) + key = _unwrap(key) + if not ( + key in _KEY_SCALARS + or (isinstance(key, type) and issubclass(key, Enum) and issubclass(key, str | int)) + ): + raise _refuse( + where, + f"a mapping keyed by {key} cannot be recorded: JSON keys a mapping by " + "str, int, bool, float or a string or integer enum.", + ) + _require_declarable(item, f"{where}[]", seen) + return + if isinstance(declared, type): + if declared in _SCALARS: + return + if issubclass(declared, Enum): + if _enum_spelling(declared) == "name": + for member in declared: + if str(member) != f"{declared.__name__}.{member.name}": + raise _refuse( + where, + f"{declared.__name__} redefines how its members print, so the " + "name it is recorded by cannot be read back.", + ) + return + if is_dataclass(declared): + if id(declared) in seen: + return + seen.add(id(declared)) + hints = typing.get_type_hints(declared, include_extras=True) + for item in fields(declared): + if not item.init: + raise _refuse( + f"{where}.{item.name}", + f"{declared.__name__}.{item.name} is not an __init__ field, so the " + "dataclass cannot be rebuilt from its recorded fields.", + ) + _require_declarable(hints[item.name], f"{where}.{item.name}", seen) + return + raise _refuse( + where, + f"{declared!r} cannot be declared: the recorded form of its values cannot be read " + "back as them. See alphalab.strategy.configuration for what can.", + ) + + +# --------------------------------------------------------------------------- # +# A value against its type +# --------------------------------------------------------------------------- # + + +def _found(value: Any) -> str: + return f"{type(value).__name__} {value!r}" + + +def _require(value: Any, declared: Any, where: str) -> None: + declared = _unwrap(declared) + if declared is Any: + _require_native(value, where) + return + if declared is None or declared is _NONE_TYPE: + if value is not None: + raise _refuse(where, f"expected None, found {_found(value)}") + return + inner = _optional(declared) + if inner is not None: + if value is not None: + _require(value, inner, where) + return + origin = get_origin(declared) + if origin is Literal: + allowed = get_args(declared) + if not any(type(value) is type(member) and value == member for member in allowed): + raise _refuse(where, f"expected one of {allowed!r}, found {_found(value)}") + return + if origin is list: + if type(value) is not list: + raise _refuse(where, f"expected a list, found {_found(value)}") + (item,) = get_args(declared) or (Any,) + for index, entry in enumerate(value): + _require(entry, item, f"{where}[{index}]") + return + if origin is tuple: + if type(value) is not tuple: + raise _refuse(where, f"expected a tuple, found {_found(value)}") + args = get_args(declared) + if len(args) == 2 and args[1] is Ellipsis: + for index, entry in enumerate(value): + _require(entry, args[0], f"{where}[{index}]") + return + if len(value) != len(args): + raise _refuse(where, f"expected {len(args)} items, found {len(value)}") + for index, (entry, item) in enumerate(zip(value, args, strict=True)): + _require(entry, item, f"{where}[{index}]") + return + if origin in (dict, Mapping): + if origin is dict and type(value) is not dict: + raise _refuse(where, f"expected a dict, found {_found(value)}") + if not isinstance(value, Mapping): + raise _refuse(where, f"expected a mapping, found {_found(value)}") + key, item = get_args(declared) or (str, Any) + for entry_key, entry in value.items(): + _require(entry_key, key, f"{where} key") + _require(entry, item, f"{where}[{entry_key!r}]") + return + if isinstance(declared, type) and is_dataclass(declared): + if type(value) is not declared: + raise _refuse(where, f"expected a {declared.__name__}, found {_found(value)}") + hints = typing.get_type_hints(declared, include_extras=True) + for item in fields(declared): + _require(getattr(value, item.name), hints[item.name], f"{where}.{item.name}") + return + if isinstance(declared, type) and issubclass(declared, Enum): + if type(value) is not declared: + raise _refuse(where, f"expected a {declared.__name__} member, found {_found(value)}") + return + if type(value) is not declared: + raise _refuse(where, f"expected {declared.__name__}, found {_found(value)}") + if (declared is float and not math.isfinite(value)) or ( + declared is Decimal and not value.is_finite() + ): + raise _refuse(where, f"{value!r} is not finite, and has no recorded form") + + +def _require_native(value: Any, where: str) -> None: + """A value declared ``Any`` must be one JSON reads back exactly.""" + + if value is None or type(value) in (bool, int, str): + return + if type(value) is float: + if not math.isfinite(value): + raise _refuse(where, f"{value!r} is not finite, and has no recorded form") + return + if type(value) is list: + for index, entry in enumerate(value): + _require_native(entry, f"{where}[{index}]") + return + if type(value) is dict: + for key, entry in value.items(): + if type(key) is not str: + raise _refuse(where, f"a key {_found(key)} reads back as text; declare its type") + _require_native(entry, f"{where}[{key!r}]") + return + raise _refuse( + where, + f"{_found(value)} is declared Any, and reads back as JSON reads it; declare its type", + ) + + +# --------------------------------------------------------------------------- # +# The recorded form back to the value +# --------------------------------------------------------------------------- # + + +def _decode(value: Any, declared: Any, where: str) -> Any: + declared = _unwrap(declared) + if declared is Any: + return value + if declared is None or declared is _NONE_TYPE: + if value is not None: + raise _refuse(where, f"expected null, found {_found(value)}") + return None + inner = _optional(declared) + if inner is not None: + return None if value is None else _decode(value, inner, where) + origin = get_origin(declared) + if origin is Literal: + for member in get_args(declared): + if type(value) is type(member) and value == member: + return member + raise _refuse(where, f"expected one of {get_args(declared)!r}, found {_found(value)}") + if origin in (list, tuple): + if type(value) is not list: + raise _refuse(where, f"expected a list, found {_found(value)}") + args = get_args(declared) + if origin is list: + (item,) = args or (Any,) + return [_decode(entry, item, f"{where}[{index}]") for index, entry in enumerate(value)] + if len(args) == 2 and args[1] is Ellipsis: + return tuple( + _decode(entry, args[0], f"{where}[{index}]") for index, entry in enumerate(value) + ) + if len(value) != len(args): + raise _refuse(where, f"expected {len(args)} items, found {len(value)}") + return tuple( + _decode(entry, item, f"{where}[{index}]") + for index, (entry, item) in enumerate(zip(value, args, strict=True)) + ) + if origin in (dict, Mapping): + if type(value) is not dict: + raise _refuse(where, f"expected an object, found {_found(value)}") + key, item = get_args(declared) or (str, Any) + decoded = { + _decode_key(entry_key, key, f"{where} key"): _decode( + entry, item, f"{where}[{entry_key!r}]" + ) + for entry_key, entry in value.items() + } + return decoded if origin is dict else types.MappingProxyType(decoded) + if isinstance(declared, type) and is_dataclass(declared): + return _decode_dataclass(value, declared, where) + if isinstance(declared, type) and issubclass(declared, Enum): + return _decode_enum(value, declared, where) + return _decode_scalar(value, declared, where) + + +def _decode_dataclass(value: Any, declared: type, where: str) -> Any: + if type(value) is not dict: + raise _refuse(where, f"expected an object of {declared.__name__}, found {_found(value)}") + hints = typing.get_type_hints(declared, include_extras=True) + names = {item.name for item in fields(declared)} + unknown = sorted(set(value) - names) + if unknown: + raise _refuse(where, f"{declared.__name__} has no field {unknown[0]!r}") + arguments: dict[str, Any] = {} + for item in fields(declared): + if item.name in value: + arguments[item.name] = _decode( + value[item.name], hints[item.name], f"{where}.{item.name}" + ) + elif item.default is MISSING and item.default_factory is MISSING: + raise _refuse( + f"{where}.{item.name}", f"missing, and {declared.__name__} has no default" + ) + # Absent with a default: the dataclass supplies it -- a field added since + # the payload was written reads back at its declared default. + try: + return declared(**arguments) + except StrategyConfigurationError: + raise + except Exception as refused: # the dataclass's own constraints refused it + raise _refuse( + where, f"{declared.__name__} refused the recorded value: {refused}" + ) from refused + + +def _decode_enum(value: Any, declared: type[Enum], where: str) -> Enum: + try: + if _enum_spelling(declared) == "value": + if isinstance(value, bool) or not isinstance(value, str | int): + raise ValueError(value) + return declared(value) + prefix = f"{declared.__name__}." + if not isinstance(value, str) or not value.startswith(prefix): + raise ValueError(value) + return declared[value.removeprefix(prefix)] + except (ValueError, KeyError) as refused: + raise _refuse(where, f"{_found(value)} is not a {declared.__name__} member") from refused + + +def _decode_scalar(value: Any, declared: type, where: str) -> Any: + if declared is bool or declared is str: + if type(value) is not declared: + raise _refuse(where, f"expected {declared.__name__}, found {_found(value)}") + return value + if declared is int: + if type(value) is not int: + raise _refuse(where, f"expected int, found {_found(value)}") + return value + if declared is float: + if type(value) is not float: + raise _refuse(where, f"expected float, found {_found(value)}") + return value + if declared is Decimal: + if type(value) is not str: + raise _refuse(where, f"expected a Decimal's text, found {_found(value)}") + try: + decoded = Decimal(value) + except InvalidOperation as refused: + raise _refuse(where, f"{value!r} is not a Decimal") from refused + if not decoded.is_finite() or str(decoded) != value: + raise _refuse(where, f"{value!r} is not the text of a finite Decimal") + return decoded + if declared is UUID: + if type(value) is not str: + raise _refuse(where, f"expected a UUID's text, found {_found(value)}") + try: + decoded_id = UUID(value) + except ValueError as refused: + raise _refuse(where, f"{value!r} is not a UUID") from refused + if str(decoded_id) != value: + raise _refuse(where, f"{value!r} is not a UUID's canonical text") + return decoded_id + raise _refuse(where, f"{declared!r} cannot be declared") # pragma: no cover - refused earlier + + +def _decode_key(value: Any, declared: Any, where: str) -> Any: + """A mapping key from its JSON text: what ``json.dumps`` writes for each key type.""" + + declared = _unwrap(declared) + if type(value) is not str: + raise _refuse(where, f"expected a key's text, found {_found(value)}") + if declared is str: + return value + if declared is bool: + if value not in ("true", "false"): + raise _refuse(where, f"{value!r} is not a bool key") + return value == "true" + if isinstance(declared, type) and issubclass(declared, Enum): + raw: Any = value + if issubclass(declared, int): + raw = _decode_key(value, int, where) + return _decode_enum(raw, declared, where) + try: + decoded = int(value) if declared is int else float(value) + except ValueError as refused: + raise _refuse(where, f"{value!r} is not a {declared.__name__} key") from refused + if (declared is int and str(decoded) != value) or ( + declared is float and repr(decoded) != value + ): + article = "an int" if declared is int else "a float" + raise _refuse(where, f"{value!r} is not the text {article} key is written as") + return decoded diff --git a/alphalab/strategy/supervisor.py b/alphalab/strategy/supervisor.py index b15bc035..71f53057 100644 --- a/alphalab/strategy/supervisor.py +++ b/alphalab/strategy/supervisor.py @@ -4,6 +4,7 @@ from typing import Any from alphalab.common.ids import new_id +from alphalab.strategy.configuration import declared_configuration, require_configuration from alphalab.strategy.events import LifecycleTransitioned from alphalab.strategy.exceptions import InvalidTransitionError from alphalab.strategy.state import StrategyState, StrategyStatus @@ -37,8 +38,25 @@ def _create_transition_event( def configure( state: StrategyState, config: Any, timestamp: float ) -> tuple[StrategyState, LifecycleTransitioned]: + """Record what the strategy is configured with. + + A strategy whose class declares ``configuration_type`` (v4.1.1, ledger + FUT-002) is configured only with a value of that type, so that a + restore reads it back exactly -- see + :mod:`alphalab.strategy.configuration`. Refused before anything moves. + + Raises: + InvalidTransitionError: If the strategy cannot be configured from + its status. + StrategyConfigurationError: If ``config`` is not of the declared + type, or the type cannot be declared. + """ + if state.status not in {StrategyStatus.CREATED, StrategyStatus.FAILED}: raise InvalidTransitionError(f"Cannot configure from {state.status.name}") + declared = declared_configuration(state.instance) + if declared is not None: + require_configuration(config, declared, f"{state.strategy_id} config") new_state = replace(state, status=StrategyStatus.CONFIGURED, config=config, last_error=None) event = RuntimeSupervisor._create_transition_event( diff --git a/docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md b/docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md index d97d39bd..c7be92d0 100644 --- a/docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md +++ b/docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md @@ -9,7 +9,8 @@ the engine-owned gaps the audit found (decisions 7-12), then the FUT-009 milestone, execution algorithms on the historical drivers (decision 13), then the FUT-013 milestone, a derivative's lifecycle declared and carried out (decision 14), then the FUT-011 milestone, a queue model for resting limit -orders (decision 15). The release number is the +orders (decision 15), then the FUT-002 milestone, a strategy configuration read +back as its declared type (decision 16). The release number is the maintainer's decision: the candidate adds public names, keyword-only parameters, an enum member and two schema steps (instrument 3 to 4, replay 1 to 2, each reading every earlier payload), which `docs/api/PUBLIC_API.md`'s policy and @@ -217,6 +218,21 @@ allocates pro rata is another model of the protocol, not a parameter of this one; refused beside an execution plan, whose children it would not see, and on a run that routes to a venue. +**16. A strategy declares its configuration's type, and reads it back as that +type (FUT-002).** ADR-0050 decision 6 left a typed configuration to 5.0 because +a typed *payload* moves every `result_id`. The type need not be in the payload: +it is in the strategy's class, which a restore is handed back. A class setting +`configuration_type` (`alphalab.strategy.configuration`) is configured only with +a value of that type -- checked strictly, so that nothing reads back as another +type -- and restore reads the recorded form, the one the encoder has always +written, back as that type: a `Decimal` from its exact text, tuples, enum +members, `UUID`s, typed keys, and dataclasses through their own constructors, +so a field added since a payload was written reads back at its default and +`__post_init__` constraints hold on the way back. A type whose values cannot be +read back is refused at configure; a recorded value not of the declared type is +refused at restore, naming the strategy and the field. No schema moves, no +identity moves, and a strategy that declares nothing reads back as before. + --- # Consequences @@ -254,9 +270,12 @@ a run that routes to a venue. hidden and iceberg liquidity, self-trade prevention and keep-priority amends are not modelled; the run's own resting orders are not in the feed, so each is queued against the market's queue independently; no fill probability is drawn. +- **An undeclared configuration reads back as JSON reads it**: the typed form is + the declaration's, since reading a configuration typed without one would need + type tags in the payload, which moves every run's identity. Each is stated in the ledger entry it belongs to (FUT-010, FUT-006, FUT-005, -FUT-009, FUT-011, FUT-014, FUT-015, FUT-016, FUT-017). +FUT-009, FUT-011, FUT-002, FUT-014, FUT-015, FUT-016, FUT-017). **Deferred** — to v4.3 and later, pending the maintainer's decision: a strategy-facing exercise instruction; settlement-date accounting; richer contract terms; margin and forced liquidation. @@ -287,6 +306,11 @@ FUT-009, FUT-011, FUT-014, FUT-015, FUT-016, FUT-017). `result_id` -- the configuration is part of the recorded run -- for a capability most runs do not use (decision 15). The book is threaded as the FX table is and persisted where the execution plan's is. +* **Type tags in a configuration's payload.** Self-describing, and readable + without the strategy's class -- and a change to every run's record and + `result_id`, including runs whose configuration is JSON-native, which this + release does not make (decision 16). The class that is handed back on restore + already knows the type. * **A fill probability drawn per event.** A seeded draw would be reproducible, but a probability is a calibration of a venue's flow the engine does not have; the shipped model is deterministic and conservative, and a probabilistic one diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 6ce36d4f..393d59ba 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -82,7 +82,7 @@ deployment reproducible, and is what this release recommends. **The public API.** Every name in `docs/api/public_api.json`: each package's `__all__`, and the `__all__` of `alphalab.api` and of each durable state's -snapshot module -- 2,678 names. `tests/regression/test_public_api_manifest.py` +snapshot module -- 2,683 names. `tests/regression/test_public_api_manifest.py` fails on any name added, removed or re-signed without the manifest changing, and each release's CHANGELOG names every removal or rebinding. Since v4.1 one signature reads the same on every supported interpreter (API-008). A name with a @@ -157,9 +157,11 @@ reads back after a restore as `docs/STATE_MODEL.md` states, row by row (LIM-004) JSON natives exactly; a `Decimal` as its exact text (rebuild it with `Decimal`), a tuple as a list, a non-string key as its text, an enum, `UUID` or dataclass as its projection; a set, a non-finite float or any other object is refused at -capture. Keep a strategy's durable parameters and memory in -`StrategyStateProtocol` state, which round-trips exactly. A typed form is -FUT-002, a 5.0 decision. +capture. A strategy whose class declares `configuration_type` -- a dataclass, +say, with `Decimal`, tuple and enum fields -- is configured only with a value of +that type and reads it back exactly, as that type (FUT-002, +`alphalab.strategy.configuration`). Keep a strategy's durable memory in +`StrategyStateProtocol` state, which round-trips exactly. **What the application owns.** The process and its scheduling, broker and data adapters, credentials, persistence locations, the decision when to pause, and @@ -171,8 +173,8 @@ between threads -- but running several runs concurrently in one process is ## What this release does not provide A vendor adapter or credential of any kind, a running service, incremental -checkpoints of a paused replay (each is a whole capture), a strategy -configuration that reads back typed (FUT-002), testing on Windows or on any +checkpoints of a paused replay (each is a whole capture), a configuration that +reads back typed without declaring its type (LIM-004), testing on Windows or on any implementation but CPython, and concurrency within one process. `ROADMAP.md` lists each boundary and limitation with its reason. diff --git a/docs/STATE_MODEL.md b/docs/STATE_MODEL.md index ef69d069..47f4621e 100644 --- a/docs/STATE_MODEL.md +++ b/docs/STATE_MODEL.md @@ -292,7 +292,12 @@ serializable projection, `from_primitives(payload)` → typed snapshot, `restore(capture(s)) == s` therefore holds for a strategy configured with values of the first row, and the run's identities are the same before and - after a restore either way. A typed form is FUT-002 (a 5.0 decision). + after a restore either way. Since the v4.2.0 candidate (ledger FUT-002) it + holds for every row but the last when the strategy's class declares the + configuration's type (`configuration_type`; `alphalab.strategy.configuration`): + `configure` refuses a value not of that type, and restore reads the recorded + form back as it -- the payload is the one this table describes, so no schema + or identity moves (`tests/regression/test_typed_strategy_configuration.py`). - **Derived indexes are rebuilt, not stored.** The order book's asset and strategy indexes and the instrument registry's provider index are reconstructed by replaying the records. One fact, one home. diff --git a/docs/api/PUBLIC_API.md b/docs/api/PUBLIC_API.md index 38048e24..01ae3494 100644 --- a/docs/api/PUBLIC_API.md +++ b/docs/api/PUBLIC_API.md @@ -23,7 +23,7 @@ durability table. | [`generate_public_api.py`](generate_public_api.py) | Writes the manifest from the package. Keeps the reasons already written; leaves a new shared name's reason empty | | `tests/regression/test_public_api_manifest.py` | Fails on any export added, removed or rebound, on a shared name without a reason, and on a manifest for another release | -At v4.1.0 the manifest records 44 packages and 12 documented modules, 2,678 exports and 35 shared names +At v4.1.0 the manifest records 44 packages and 12 documented modules, 2,683 exports and 35 shared names (`test_public_api_manifest.py` holds this sentence to the manifest). ## Stability from v4.0 diff --git a/docs/api/public_api.json b/docs/api/public_api.json index ab1abacf..abed9f0c 100644 --- a/docs/api/public_api.json +++ b/docs/api/public_api.json @@ -2713,6 +2713,7 @@ "AdaptiveStrategy": "class alphalab.strategy.adaptive_strategy.AdaptiveStrategy(strategy_id: 'str', configuration: 'AdaptiveConfiguration', rule: 'AdaptiveRule', mode: 'AdaptationMode', initial: 'AdaptiveState | None') -> 'None'", "AdaptiveTransition": "class alphalab.strategy.adaptive.AdaptiveTransition(observation_id: 'str', kind: 'TransitionKind', before: 'str', after: 'str') -> None", "BaseStrategy": "class alphalab.strategy.protocol.BaseStrategy()", + "CONFIGURATION_ATTRIBUTE": "value builtins.str", "ClockProtocol": "class alphalab.common.time.ClockProtocol(*args, **kwargs)", "DecisionTiming": "enum alphalab.strategy.adaptive.DecisionTiming[BEFORE_UPDATE, AFTER_UPDATE]", "DiscardingLogger": "class alphalab.strategy.context.DiscardingLogger()", @@ -2750,6 +2751,7 @@ "SliceStrategyProtocol": "class alphalab.strategy.protocol.SliceStrategyProtocol(*args, **kwargs)", "StateValue": "alias StateValue", "StrategyClassRegistry": "class alphalab.strategy.registry.StrategyClassRegistry(registrations: 'PersistentMap[str, StrategyRegistration]' = ) -> None", + "StrategyConfigurationError": "class alphalab.strategy.configuration.StrategyConfigurationError", "StrategyContext": "class alphalab.strategy.context.StrategyContext(portfolio: alphalab.strategy.context.PortfolioSnapshotProtocol, market: alphalab.strategy.context.MarketViewProtocol, clock: alphalab.common.time.ClockProtocol, logger: alphalab.strategy.context.ScopedLoggerProtocol, risk_view: alphalab.strategy.context.RiskViewProtocol, config: Any, orders: alphalab.strategy.context.OrderFacadeProtocol, history: alphalab.strategy.context.HistoryAccessorProtocol = , universe: alphalab.strategy.context.UniverseProtocol = ) -> None", "StrategyDeclaration": "class alphalab.strategy.registry.StrategyDeclaration(*args, **kwargs)", "StrategyDefinition": "class alphalab.strategy.definition.StrategyDefinition(strategy_id: 'str', name: 'str', version: 'str', author: 'str', description: 'str', parameters: 'Mapping[str, ParamValue]' = , metadata: 'Mapping[str, str]' = ) -> None", @@ -2777,6 +2779,8 @@ "checkpoint": "function alphalab.strategy.adaptive.checkpoint(state: 'AdaptiveState') -> 'dict[str, Any]'", "context_factory": "function alphalab.strategy.context.context_factory(clock: alphalab.common.time.ClockProtocol, logger: alphalab.strategy.context.ScopedLoggerProtocol) -> collections.abc.Callable[[str], alphalab.strategy.context.StrategyContext]", "create_runtime": "function alphalab.strategy.runtime.create_runtime() -> alphalab.strategy.state.RuntimeState", + "declared_configuration": "function alphalab.strategy.configuration.declared_configuration(instance: 'object') -> 'Any | None'", + "decode_configuration": "function alphalab.strategy.configuration.decode_configuration(value: 'Any', declared: 'Any', where: 'str' = 'config') -> 'Any'", "defines_on_observation": "function alphalab.strategy.protocol.defines_on_observation(strategy: object) -> bool", "defines_on_slice": "function alphalab.strategy.protocol.defines_on_slice(strategy: object) -> bool", "failed_strategies": "function alphalab.strategy.views.failed_strategies(state: alphalab.strategy.state.RuntimeState) -> collections.abc.Sequence[alphalab.strategy.state.StrategyState]", @@ -2787,6 +2791,7 @@ "observation_stream": "function alphalab.strategy.adaptive.observation_stream(stream: 'str', rows: 'Iterable[tuple[float, Mapping[str, float]]]', *, first_sequence: 'int') -> 'tuple[AdaptiveObservation, ...]'", "register_strategy": "function alphalab.strategy.runtime.register_strategy(state: alphalab.strategy.state.RuntimeState, strategy_id: str, instance: alphalab.strategy.protocol.StrategyProtocol) -> alphalab.strategy.state.RuntimeState", "replay_updates": "function alphalab.strategy.adaptive.replay_updates(configuration: 'AdaptiveConfiguration', rule: 'AdaptiveRule', observations: 'Iterable[AdaptiveObservation]', mode: 'AdaptationMode', initial: 'AdaptiveState | None' = None) -> 'AdaptiveReplay'", + "require_configuration": "function alphalab.strategy.configuration.require_configuration(value: 'Any', declared: 'Any', where: 'str' = 'config') -> 'None'", "restore": "function alphalab.strategy.adaptive.restore(payload: 'Mapping[str, Any]', configuration: 'AdaptiveConfiguration', rule: 'AdaptiveRule') -> 'AdaptiveState'", "runtime_for": "function alphalab.strategy.registry.runtime_for(registry: 'StrategyClassRegistry', declarations: 'Iterable[StrategyDeclaration]') -> 'RuntimeState'", "start_strategy": "function alphalab.strategy.runtime.start_strategy(state: alphalab.strategy.state.RuntimeState, strategy_id: str, instance: alphalab.strategy.protocol.StrategyProtocol, *, config: Any, subscriptions: collections.abc.Iterable[str], at: float) -> alphalab.strategy.state.RuntimeState", diff --git a/docs/audit/FUTURE_READINESS_V4_1_1.md b/docs/audit/FUTURE_READINESS_V4_1_1.md index 596ccf01..26ba692c 100644 --- a/docs/audit/FUTURE_READINESS_V4_1_1.md +++ b/docs/audit/FUTURE_READINESS_V4_1_1.md @@ -21,8 +21,9 @@ milestone** put execution algorithms on the historical drivers (FUT-009). expiry, delivery -- the contract's declared terms, carried out by the run in any market a calendar describes, and named the gaps it found (FUT-014 to FUT-017). **The FUT-011 milestone** made a resting limit order's passive fills depend on a -declared queue model. Accepted as future work: FUT-014 to FUT-017, with FUT-002 -from v4.1. Every figure below was read from a command's own +declared queue model, and **the FUT-002 milestone** let a strategy declare its +configuration's type and read it back as that type. Accepted as future work: +FUT-014 to FUT-017. Every figure below was read from a command's own output in the session that produced it. --- @@ -235,8 +236,9 @@ with that reason, as `rates` already was. | FUT-009 | `alphalab.backtesting.algorithmic`: `ExecutionPlan` (per-asset `AlgorithmAssignment`s, an optional `VenueCheck`) on `BacktestEngine.run`, `ReplayBacktest.run` and the facade; `ExecutionRouting.ALGORITHMIC` holds every parent in the OMS and refuses stop, auction and immediate terms; `ExecutionPipeline.work_child` fills each child by the run's fill policy and cost model and settles it on the parent; the `AlgorithmBook` on the result, in replay schema 2, and the plan's id in the manifest | `tests/integration/test_algorithms_on_the_historical_path.py` (57), `test_execution_algorithm_example.py` (4), two manifest tests; example 74; `benchmarks/benchmark_algorithmic_execution.py` | | FUT-013 | `alphalab.instrument.terms`: `OptionTerms` and `FutureTerms` on `InstrumentRecord.terms` (instrument schema 4, outside the identity key); `Exercise`, `Assignment`, `OptionExpiry` on the schedule and as run operations, through the portfolio's fill authority and each strategy's share; physical futures delivered at their final price; `AssetType.INDEX`; `session_close`, `settles_at` | `tests/integration/test_option_lifecycle.py` (58), `test_lifecycle_conventions.py` (9), `test_lifecycle_example.py` (4); example 75 | | FUT-011 | `alphalab.execution.queue`: `QueueModel`, `PriceTimeQueue` (price-time priority over displayed liquidity), `QueuePosition`, `QueueTurn`, `QueueObservation`, `QueueBook`; `queue=` on `BacktestEngine.run`, `ReplayBacktest.run` and the facade; `QueuedResult` and `ExecutionAssumptions.queue_model` / `passive_fills`; the book threaded through `RunEngine.advance` and the pipeline step as a parameter (no pipeline, run or config schema moves, no `result_id` moves); `alphalab.backtesting.queueing` (`QUEUE_BOOK_SCHEMA = 1`) carried by a paused replay (schema 2) and held to its model on restore; `ReproducibilityManifest.queue_model` | `tests/integration/test_queue_model.py` (32: every rule of the model, fills against the same run without it, partial fills, depletion short of the front, bars, the fill policy within the queue, whole units, expiry, a split, a triggered stop-limit, taking orders untouched, an unknown queue, replay parity, a pause at every boundary resumed, refusals, an unchanged record); `tests/unit/lifecycle/test_reproducibility.py` (the manifest) | +| FUT-002 | `alphalab.strategy.configuration`: a strategy class declares `configuration_type`; `RuntimeSupervisor.configure` refuses a value not of that type (`StrategyConfigurationError`, naming the field) and restore reads the recorded value back as it -- `Decimal`, tuples, enums, `UUID`, typed keys, read-only mappings, `Literal`, optional fields and dataclasses through their constructors (defaults and constraints held); the payload unchanged, so no schema or identity moves; an undeclared configuration reads back as before (LIM-004) | `tests/regression/test_typed_strategy_configuration.py` (62: a nested configuration equal and of every type; every converted LIM-004 row exact when declared; payload, `result_id` and `configuration_id` unchanged; refusals at configure and at restore; a default for a field an older payload lacks; a paused replay); 13 mutants of the checks each caught | -**Guards extended, none weakened.** The replay snapshot's field pin names +**Guards extended, none weakened.** The LIM-004 contract suite is unchanged and passes; its docstring names the typed path; the ADR-deferral classifier lists ADR-0051's new limitation (an undeclared configuration, LIM-004). The replay snapshot's field pin names `PausedReplay.schedule`; the schema-upgrade suites pin instrument schema 4 and replay schema 2 with an upgrade test each, the v4.1.0 payloads frozen in `tests/fixtures` still restoring through them; the persisted-enum pin adds @@ -249,7 +251,7 @@ ADR-deferral classifier covers ADR-0051's entries. **Mutation evidence.** Each guard named here was shown to fail against the defect it guards, in a scratch copy of the package imported ahead of the -checkout (`-o pythonpath= --import-mode=append`): in the audit, the replay merging observations *after* closing the slice fails the backtest-replay parity test (after the workload was strengthened so a reading's size depends on the slices seen -- the first version did not distinguish the two orders, which this check found), and v4.1.0's `MarketView.bar` fails four bar tests and four acceptance tests; in the continuation, four mutants, each caught by the test written for it -- a cursor that counts the inputs after a record's instant as applied (`test_scheduled_inputs.py::test_a_replay_paused_anywhere_through_a_payload_keeps_every_input`), a restore that ignores the schedule's digest (`test_heterogeneous_inputs_on_every_driver.py::test_a_payload_resumed_with_another_schedule_or_none_is_refused`), a settlement that leaves orders working (`test_contract_expiry.py::test_an_order_still_working_at_settlement_is_cancelled`), and a manifest that trusts a dataset's stated parts (`test_multi_asset_composition.py::test_a_dataset_stating_parts_it_was_not_derived_from_is_refused`); in the FUT-009 milestone, eight mutants, each caught by `test_algorithms_on_the_historical_path.py` -- a child allowed to work more than its parent has left, a child trading at its release instant, a child outliving its slice, a parent ended without its reservation released, a child fill reported as completing the parent, algorithmic orders filled whole, a book trusting an altered payload, and the capability check skipped. Two of the eight survived the first test file -- the release-instant rule needed two records at one instant, and filling whole showed only under `SAME_EVENT` -- and the tests that catch them were added. In the FUT-013 milestone, twelve mutants, each caught by the lifecycle tests -- a European option exercised early, a put paying the call's payoff, a delivery financed or shorted silently, the exercise threshold ignored, a physical option closed at its intrinsic value (booking it twice), the deliverable's multiplier assumed one, partial-exercise ties given to the later strategy, delivered shares left at the strike, an index made tradable, the registry dropping terms, an exercise beyond the position; the delivered-shares mutant survived the first test, which read the mark only after later prices had re-marked the shares, and the test now reads the equity point at the delivery itself. In the FUT-011 milestone, fifteen mutants, each caught by `test_queue_model.py` -- prints that leave the queue unconsumed, prints by the order's own side consuming it, a displayed size adding to the queue, a vacated level keeping it, a touch filling behind the queue, a join at the best price put at the front, the queue's cap ignored, its refusal ignored, no join at placement, the book never pruned during the run, a pause not pruned, a triggered stop-limit never joining, whole units not floored, a pause dropping the book, a restore ignoring the model; three survived the first test file -- the cap made the refusal look redundant, and two pruning layers each hid the other -- and the tests that catch them were added. +checkout (`-o pythonpath= --import-mode=append`): in the audit, the replay merging observations *after* closing the slice fails the backtest-replay parity test (after the workload was strengthened so a reading's size depends on the slices seen -- the first version did not distinguish the two orders, which this check found), and v4.1.0's `MarketView.bar` fails four bar tests and four acceptance tests; in the continuation, four mutants, each caught by the test written for it -- a cursor that counts the inputs after a record's instant as applied (`test_scheduled_inputs.py::test_a_replay_paused_anywhere_through_a_payload_keeps_every_input`), a restore that ignores the schedule's digest (`test_heterogeneous_inputs_on_every_driver.py::test_a_payload_resumed_with_another_schedule_or_none_is_refused`), a settlement that leaves orders working (`test_contract_expiry.py::test_an_order_still_working_at_settlement_is_cancelled`), and a manifest that trusts a dataset's stated parts (`test_multi_asset_composition.py::test_a_dataset_stating_parts_it_was_not_derived_from_is_refused`); in the FUT-009 milestone, eight mutants, each caught by `test_algorithms_on_the_historical_path.py` -- a child allowed to work more than its parent has left, a child trading at its release instant, a child outliving its slice, a parent ended without its reservation released, a child fill reported as completing the parent, algorithmic orders filled whole, a book trusting an altered payload, and the capability check skipped. Two of the eight survived the first test file -- the release-instant rule needed two records at one instant, and filling whole showed only under `SAME_EVENT` -- and the tests that catch them were added. In the FUT-013 milestone, twelve mutants, each caught by the lifecycle tests -- a European option exercised early, a put paying the call's payoff, a delivery financed or shorted silently, the exercise threshold ignored, a physical option closed at its intrinsic value (booking it twice), the deliverable's multiplier assumed one, partial-exercise ties given to the later strategy, delivered shares left at the strike, an index made tradable, the registry dropping terms, an exercise beyond the position; the delivered-shares mutant survived the first test, which read the mark only after later prices had re-marked the shares, and the test now reads the equity point at the delivery itself. In the FUT-011 milestone, fifteen mutants, each caught by `test_queue_model.py` -- prints that leave the queue unconsumed, prints by the order's own side consuming it, a displayed size adding to the queue, a vacated level keeping it, a touch filling behind the queue, a join at the best price put at the front, the queue's cap ignored, its refusal ignored, no join at placement, the book never pruned during the run, a pause not pruned, a triggered stop-limit never joining, whole units not floored, a pause dropping the book, a restore ignoring the model; three survived the first test file -- the cap made the refusal look redundant, and two pruning layers each hid the other -- and the tests that catch them were added. In the FUT-002 milestone, thirteen mutants, each caught by `test_typed_strategy_configuration.py` -- configure unchecked, restore untyped, an int read back as a float, a Decimal's text not held exact, a missing field and an unknown field not refused, a union admitted, an `Any` value not held to JSON-native, a non-canonical key admitted, an enum name read without its class, a subclass admitted for its base, a non-finite Decimal admitted, a mapping read back mutable; four survived the first test file -- the refusals each needed a case of its own -- and the cases were added. --- @@ -308,25 +310,25 @@ well designed, or that any of it behaves the same against a real venue. ### 8.1 Commands and results on the final tree Run in the checkout's dev venv (CPython 3.12.4) unless stated; the figures are -the FUT-011 milestone's, on the tree it committed. +the FUT-002 milestone's, on the tree it committed (the FUT-011 commit's own, measured the same way, are in its commit message: 10,053 / 10,054 passed). | Gate | Command | Result | | --- | --- | --- | | Lint | `python -m ruff check .` | All checks passed | -| Format | `python -m ruff format --check .` | 1,313 files already formatted | -| Types | `python -m mypy .` | no issues in 1,211 source files | -| Tests | `python -m pytest -W error` | **10,053 passed, 3 failed** -- exactly the three version-keyed guards of section 8.2 (one of them the dev venv's stale metadata); 0 skipped | +| Format | `python -m ruff format --check .` | 1,315 files already formatted | +| Types | `python -m mypy .` | no issues in 1,213 source files | +| Tests | `python -m pytest -W error` | **10,115 passed, 3 failed** -- exactly the three version-keyed guards of section 8.2 (one of them the dev venv's stale metadata); 0 skipped | | Examples | every `examples/[0-9]*.py` under `python -W error` | all 75 exit 0 | | Certification | `python -W error docs/audit/scripts/certify_release.py --check` | 11 checks passed, matching the re-certified certificate (only the source digest and the API manifest's digest and export count moved; no determinism, parity, reproducibility or numerical figure, and no `result_id`) | -| Public API | `python docs/api/generate_public_api.py`, then the manifest tests | 2,678 exports, 35 shared names (2,628 at v4.1.0); additions and defaulted keywords or fields only, none removed or re-signed incompatibly (CHANGELOG `[Unreleased]` lists each) | -| Fresh 3.12 | the suite in a fresh venv from a clean mirror | CPython 3.12.4: **10,054 passed, 2 failed** (the two version-keyed guards; the metadata test passes) | -| CPython 3.13 | the suite on 3.13.17 (python-build-standalone, scratch) | CPython 3.13.17: **10,054 passed, 2 failed** (the same two) | -| CPython 3.14 | the suite on 3.14.9 | CPython 3.14.9: **10,054 passed, 2 failed** (the same two) | +| Public API | `python docs/api/generate_public_api.py`, then the manifest tests | 2,683 exports, 35 shared names (2,628 at v4.1.0); additions and defaulted keywords or fields only, none removed or re-signed incompatibly (CHANGELOG `[Unreleased]` lists each) | +| Fresh 3.12 | the suite in a fresh venv from a clean mirror | CPython 3.12.4: **10,116 passed, 2 failed** (the two version-keyed guards; the metadata test passes) | +| CPython 3.13 | the suite on 3.13.17 (python-build-standalone, scratch) | CPython 3.13.17: **10,116 passed, 2 failed** (the same two) | +| CPython 3.14 | the suite on 3.14.9 | CPython 3.14.9: **10,116 passed, 2 failed** (the same two) | | Build | `python -m build` in the clean mirror | `alphalab-4.1.0.tar.gz` and `alphalab-4.1.0-py3-none-any.whl` (labelled 4.1.0 until the version is set; kept in the scratchpad, `dist/` untouched) | | Distributions | `python -m twine check --strict dist/*` | both PASSED | -| Installed artifacts | `python docs/audit/scripts/release_preflight.py distributions dist --version 4.1.0 --record dist/VERIFICATION-4.1.0.txt` (each artifact alone in a fresh environment, smoke-tested from outside the tree, examples 11 and 70 to 75 with the network refused) | **PASSED**: each artifact installed alone, `pip check` clean, 611 modules, 2,678 public names as the manifest records, examples 11 and 70 to 75 and the consumer contract run with the network refused; wheel `f88dfe1b...6b79`, sdist `b3cb9fec...a354` | -| Benchmarks | `benchmarks/benchmark_{backtesting,replay_engine,runtime,execution_pipeline,strategy_runtime}.py` under `-W error`, on this tree and on `git archive v4.1.0`, one after the other on one host | no regression on the final tree: judged backtest scaling 3.98x against v4.1.0's 4.03x, replay 1.02x the backtest against 1.03x, run layer +2.56% against +2.07% over the bare step (within the run-to-run spread the earlier milestones measured, +2.10% to +2.33%), pipeline 3.99x against 4.01x; every parity line identical. A queue model, measured on its own: a run resting about 150 orders at every one of 6,000 quote and print events costs 7.42 s with `PriceTimeQueue` against 6.84 s without (median of five; 149 fills each) | -| Mutation | the FUT-011 rules, each broken in a scratch copy | 15 of 15 caught by `tests/integration/test_queue_model.py` | +| Installed artifacts | `python docs/audit/scripts/release_preflight.py distributions dist --version 4.1.0 --record dist/VERIFICATION-4.1.0.txt` (each artifact alone in a fresh environment, smoke-tested from outside the tree, examples 11 and 70 to 75 with the network refused) | **PASSED**: each artifact installed alone, `pip check` clean, 612 modules, 2,683 public names as the manifest records, examples 11 and 70 to 75 and the consumer contract run with the network refused; wheel `94d8998c...d117`, sdist `66474307...5d5a` | +| Benchmarks | `benchmarks/benchmark_{backtesting,replay_engine,runtime,execution_pipeline,strategy_runtime}.py` under `-W error`, on this tree and on `git archive v4.1.0`, one after the other on one host | no regression on the final tree: judged backtest scaling 3.98x against v4.1.0's 4.03x, replay 1.02x the backtest against 1.03x, run layer +2.56% against +2.07% over the bare step (within the run-to-run spread the earlier milestones measured, +2.10% to +2.33%), pipeline 3.99x against 4.01x; every parity line identical. A queue model, measured on its own: a run resting about 150 orders at every one of 6,000 quote and print events costs 7.42 s with `PriceTimeQueue` against 6.84 s without (median of five; 149 fills each). Measured on the FUT-011 tree; FUT-002 adds nothing that runs per event (a configuration is checked at configure and decoded at restore), so the run benchmarks were not repeated for it | +| Mutation | the FUT-011 rules and the FUT-002 checks, each broken in a scratch copy | 15 of 15 caught by `tests/integration/test_queue_model.py`; 13 of 13 by `tests/regression/test_typed_strategy_configuration.py` | | Whitespace | `git diff --check` | clean | ### 8.2 The three guards that wait for the version @@ -362,8 +364,9 @@ Windows remains untested (BDY-027). **Recommendation: release this candidate as v4.2.0.** It adds a public module (`alphalab.backtesting.schedule`) and modules (`alphalab.backtesting.algorithmic`, `alphalab.backtesting.queueing`, `alphalab.execution.queue`, -`alphalab.instrument.terms`), names in `alphalab.api`, `alphalab.portfolio`, -`alphalab.instrument`, `alphalab.runtime` and `alphalab.backtesting`, three +`alphalab.instrument.terms`, `alphalab.strategy.configuration`), names in +`alphalab.api`, `alphalab.portfolio`, `alphalab.instrument`, `alphalab.runtime`, +`alphalab.strategy` and `alphalab.backtesting`, three persisted enum members (`AssetType.BOND`, `AssetType.INDEX`, `ExecutionRouting.ALGORITHMIC`), and defaulted keywords or fields on existing bindings; it moves two persisted @@ -400,15 +403,16 @@ check them): | FUT-016 | baskets, cash in lieu, quanto, Bermudan exercise | v4.3 | further declared terms | | FUT-015 | settlement-date (T+n) accounting | v5.0 | a portfolio schema step | | FUT-017 | margin, collateral and forced liquidation | v5.0 | new capability; depends on FUT-015 | -| FUT-002 | a lossless typed form of a strategy configuration | v5.0 | a pipeline schema step (predates this audit) | **Release-blocking for the candidate:** the version decision (section 9) and -the three guards it closes; hosted CI on Linux and macOS for the pushed branch. +the three guards it closes; hosted CI on Linux and macOS for the branch's last +commit, recorded on draft PR #18. No engine gap above blocks it: each is accepted future work with its interim behaviour stated. **Next, in dependency order:** (1) the maintainer chooses the number and the -checklist is applied; (2) the branch is pushed and hosted CI runs; (3) FUT-014, +checklist is applied; (2) hosted CI on the pushed branch is read (draft PR #18); +(3) FUT-014, which lets a strategy's own hooks take the exercise decisions FUT-013 now carries out, on the same lifecycle path; (4) FUT-016, the contract terms the lifecycle refuses today; (5) FUT-015 then FUT-017, which change the diff --git a/docs/audit/PRE_V4_COMPLETION_LEDGER.yaml b/docs/audit/PRE_V4_COMPLETION_LEDGER.yaml index 14660c9e..03efcb9f 100644 --- a/docs/audit/PRE_V4_COMPLETION_LEDGER.yaml +++ b/docs/audit/PRE_V4_COMPLETION_LEDGER.yaml @@ -5405,24 +5405,23 @@ findings: - id: "FUT-002" category: "future_work" subsystem: "runtime; strategy" - module: "alphalab/runtime/snapshot.py (StrategyRecord.config)" + module: "alphalab/strategy/configuration.py; alphalab/strategy/supervisor.py; alphalab/runtime/snapshot.py (StrategyRecord.config)" finding: "A strategy's configure value reads back after a restore as JSON reads it (LIM-004): a Decimal as its text, a tuple as a list. v4.1 states the conversion as a tested contract; a lossless typed form needs a pipeline schema step." evidence: "v4.1 examination of LIM-004 against docs/STATE_MODEL.md and docs/api/PUBLIC_API.md." severity: "low" - current_state: "Since v2.9." - required_change: "FUTURE (ROADMAP 'Future work'). Limitation: restore(capture(s)) == s does not hold for a strategy configuration holding a Decimal, a tuple, a non-string mapping key, an enum, a UUID or a dataclass; each reads back in the converted form the contract states. Impact: an application that rebuilds a strategy from the configuration a restored run recorded must convert those values itself (a Decimal from its text, exactly); nothing on the execution path reads the value, and identities are unaffected. Reason for deferral: a typed form changes the pipeline payload and so every run's result_id, which docs/api/PUBLIC_API.md's stability policy does not allow a 4.x release to do; it is the maintainer's decision whether to take it in 5.0 or as a stated 4.x exception. Interim safe behaviour: the conversion is exact and tested (tests/regression/test_strategy_configuration_contract.py); keep durable strategy parameters in StrategyStateProtocol state, which round-trips exactly, or configure with JSON-native values. Target horizon: v5.0, pending the maintainer's decision. Dependencies: a pipeline schema step reading every earlier payload, frozen v4.x payloads, and an identity-change row in that release's migration table. Completion criteria: restore(capture(s)) == s for every value the contract converts, older payloads read as they read today, and this entry closed." - rationale: "Deferred work kept where it is tracked is work." - release: "v4.1.0" - implementation_status: "accepted as future work (v5.0, pending the maintainer's decision): ROADMAP Future work FUT-002" - test_status: "tests/regression/test_strategy_configuration_contract.py::test_a_converted_value_reads_back_as_the_contract_states" - benchmark_status: "n/a" - documentation_status: "ROADMAP.md (Future work); docs/STATE_MODEL.md; docs/INTEGRATION.md" - api_impact: "a persisted-format change, in a major release" - architecture_impact: "local" - performance_impact: "neutral" - target_horizon: "v5.0" - verified: "v4.1 examination" - v41_outcome: "Accepted as future work, with the decision it needs named." + current_state: "Since v2.9; as of the v4.2.0 candidate, before the FUT-002 milestone." + required_change: "DELIVERED (v4.2.0 candidate), by declaration and without a schema step: a strategy class declares its configuration's type as configuration_type (alphalab.strategy.configuration), and RuntimeSupervisor.configure refuses a value not of that type -- strictly (an int is not a float, a bool not an int, a str subclass not a str, a non-finite float or Decimal has no recorded form), naming the field -- while restore reads the recorded value back as that type, so restore(capture(s)) == s, equal and of the same types at every depth, for every value the contract converts: a Decimal from its exact text (exponent kept), tuples (variadic and fixed), StrEnum, IntEnum and plain Enum members, UUIDs, int, bool, float and string-or-integer-enum mapping keys, Mapping fields (as read-only mappings), T | None, Literal, Annotated, and dataclasses rebuilt through their own constructors, so their defaults (a field missing from an older payload reads back at its default) and __post_init__ constraints hold on the way back. Any is admitted for JSON-native values, which read back exactly. A type whose values cannot be read back -- a set, a datetime, a union of two non-None types, a mapping keyed by Decimal, UUID or a plain Enum, a class that is not a dataclass, a dataclass with a non-init field, an enum that redefines how it prints -- is refused at configure; a recorded value that is not the encoder's form of the declared type -- a number for a Decimal, non-canonical text, an unknown or missing field, a value its dataclass refuses -- is refused at restore with StateDecodeError naming the strategy and field. The payload is what the encoder has always written, so no schema moves and no run's result_id, configuration_id or manifest moves, and every earlier payload is read as it was; a strategy that declares nothing reads back as JSON reads it (LIM-004, unchanged). The typed form is the declaration's: reading an undeclared configuration typed would need type tags in the payload, which moves every result_id and stays a 5.0 decision." + rationale: "A configuration a strategy declares is configuration it can rely on after a restore; declaring it is the researcher's contract, not a fixed list of templates." + release: "v4.2.0" + implementation_status: "implemented (v4.2.0 candidate)" + test_status: "tests/regression/test_typed_strategy_configuration.py::test_a_declared_configuration_reads_back_equal_and_of_every_type" + benchmark_status: "n/a (read at configure and at restore only, never per event)" + documentation_status: "docs/STATE_MODEL.md; docs/INTEGRATION.md; docs/ADR/0051-v4-1-1-information-on-every-driver-and-the-strategy-capability-audit.md; docs/audit/FUTURE_READINESS_V4_1_1.md" + api_impact: "additive (a module, a class attribute a strategy may declare, an exception)" + architecture_impact: "local" + performance_impact: "neutral: nothing runs per event; a strategy that declares nothing is not inspected beyond one attribute read at configure and restore" + verified: "v4.1.1 FUT-002 milestone: 62 tests (a nested declared configuration read back equal and of every type; each converted row of the LIM-004 contract exact when declared; the payload, result_id and configuration_id unchanged; values refused at configure; types refused; a defaulted field absent from the payload; recorded values refused on restore; an undeclared strategy unchanged; a paused replay resumed with the declared configuration), the LIM-004 contract suite unchanged; thirteen mutants of the checks each caught" + candidate_outcome: "Delivered for declared configurations; an undeclared configuration keeps the stated JSON reading (LIM-004), by design." - id: "PKG-003" category: "packaging" subsystem: "release" diff --git a/docs/audit/RELEASE_CERTIFICATION.md b/docs/audit/RELEASE_CERTIFICATION.md index c01f9f41..4330b4e6 100644 --- a/docs/audit/RELEASE_CERTIFICATION.md +++ b/docs/audit/RELEASE_CERTIFICATION.md @@ -7,7 +7,7 @@ Generated by `python docs/audit/scripts/certify_release.py` (ledger FEA-006) fro | Build | | | --- | --- | | Engine | alphalab 4.1.0 | -| Source digest | `eaa972bc7510d6d46d67f5173dbb129856c21ecf7c1164e1d19093ada6796684` | +| Source digest | `5c65ed1b1d17ebff15766aa3f27f1bd611bddb76a897dc5c77ee55770f40a364` | | Time-zone database | 2024a | | Certified on | Python 3.12.4 (CPython), macOS-27.0.1-arm64-arm-64bit | @@ -105,10 +105,10 @@ Generated by `python docs/audit/scripts/certify_release.py` (ledger FEA-006) fro ### API-1 -- `manifest_digest`: `c288b0cff1e17b3504c4c62cc38bc41d5c0b42b19417bdabd72a710d6ec0da74` +- `manifest_digest`: `3cba4042a14c70f0c3b6c2c2a4a5c71c09748a96de28063bb4665e26509015b3` - `packages`: `44` - `documented_modules`: `12` -- `exports`: `2678` +- `exports`: `2683` - `shared_names`: `35` ## What this does not certify diff --git a/docs/audit/STRATEGY_CAPABILITY_MATRIX.md b/docs/audit/STRATEGY_CAPABILITY_MATRIX.md index c9fd8c32..3df60e59 100644 --- a/docs/audit/STRATEGY_CAPABILITY_MATRIX.md +++ b/docs/audit/STRATEGY_CAPABILITY_MATRIX.md @@ -159,7 +159,7 @@ dependencies; **(12)** acceptance criteria for "ready". | (5) | persistence codec. | | (6) | every driver; FileRunStateStore. | | (7) | B, Re (pause/resume, v4.1), P, L. | -| (8) | **VERIFIED**. A strategy configuration's `Decimal`/tuple reads back as JSON reads it: **LIMITATION** LIM-004 / future FUT-002. | +| (8) | **VERIFIED**. A strategy that declares its configuration's type reads it back typed (v4.2.0 candidate, FUT-002); an undeclared configuration's `Decimal`/tuple reads back as JSON reads it: **LIMITATION** LIM-004. | | (9) | `tests/regression/test_durable_run_continuation.py`, `test_replay_pause_and_resume.py` (135), `test_restore_requires_the_captured_configuration.py`; v4.1.1: example 72 stopped at **every** record boundary, stored, restored with a registry-built instance and continued to the identical `result_id` (`test_multi_asset_composition.py::test_a_backtest_stored_at_any_record_and_continued_is_the_same_result`). | | (10) | A strategy that does not declare its state continues only conditionally (documented in ADR-0025). | | (11) | none. | diff --git a/docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md b/docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md index 0e789971..4aee1bce 100644 --- a/docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md +++ b/docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md @@ -9,7 +9,8 @@ FUT-003 to FUT-012; its continuation delivered FUT-003 to FUT-008, FUT-010 and FUT-012 and raised FUT-013, the FUT-009 milestone delivered FUT-009, and the FUT-013 milestone delivered FUT-013 and raised FUT-014 to FUT-017, and the FUT-011 milestone delivered FUT-011, so what is open is FUT-014 to FUT-017 -(FUT-002 predates the audit). Rows below are as of the FUT-011 milestone. +(FUT-002, which predates the audit, was delivered by declaration in the +FUT-002 milestone). Rows below are as of the FUT-011 milestone. ## Classes diff --git a/docs/audit/release_certification.json b/docs/audit/release_certification.json index 08bd1f79..509b37e9 100644 --- a/docs/audit/release_certification.json +++ b/docs/audit/release_certification.json @@ -2,7 +2,7 @@ "release": "4.1.0", "build": { "engine": "alphalab 4.1.0", - "source_digest": "eaa972bc7510d6d46d67f5173dbb129856c21ecf7c1164e1d19093ada6796684", + "source_digest": "5c65ed1b1d17ebff15766aa3f27f1bd611bddb76a897dc5c77ee55770f40a364", "tz_database": "2024a" }, "environment": { @@ -133,10 +133,10 @@ "claim": "The build's public API is the one docs/api/public_api.json records for this release.", "passed": true, "evidence": { - "manifest_digest": "c288b0cff1e17b3504c4c62cc38bc41d5c0b42b19417bdabd72a710d6ec0da74", + "manifest_digest": "3cba4042a14c70f0c3b6c2c2a4a5c71c09748a96de28063bb4665e26509015b3", "packages": 44, "documented_modules": 12, - "exports": 2678, + "exports": 2683, "shared_names": 35 } } diff --git a/tests/regression/test_every_adr_deferral_is_classified.py b/tests/regression/test_every_adr_deferral_is_classified.py index 6df2ff1e..d6d39c9d 100644 --- a/tests/regression/test_every_adr_deferral_is_classified.py +++ b/tests/regression/test_every_adr_deferral_is_classified.py @@ -142,6 +142,7 @@ # FUT-011 delivered a queue model in the candidate; ADR-0051 now states what # it does not model, which the ledger entry states too. ("0051", "The queue model reads the displayed top of the book"): "FUT-011", + ("0051", "An undeclared configuration reads back as JSON reads it"): "LIM-004", ("0051", "A strategy cannot instruct an exercise from its hooks"): "FUT-014", ("0051", "Cash and deliverables move at the event's instant"): "FUT-015", ("0051", "One deliverable, in the consideration's currency, American or European"): "FUT-016", diff --git a/tests/regression/test_nothing_is_left_for_later.py b/tests/regression/test_nothing_is_left_for_later.py index 7b1a60bd..a7df14a9 100644 --- a/tests/regression/test_nothing_is_left_for_later.py +++ b/tests/regression/test_nothing_is_left_for_later.py @@ -91,8 +91,9 @@ def test_accepted_future_work_is_stated_and_on_the_roadmap() -> None: assert "\n# Future work" in roadmap, "ROADMAP has no Future work section" future = roadmap.split("\n# Future work", 1)[1].split("\n# ", 1)[0] accepted = [(i, s) for i, s, _ in _entries() if s.startswith("accepted as future work (")] - # v4.0 accepted FUT-001, which v4.1 delivered; v4.1 accepts FUT-002. - assert accepted, "a guard on the guard: v4.1 accepts FUT-002" + # v4.0 accepted FUT-001, which v4.1 delivered; v4.1 accepted FUT-002, which + # the v4.2.0 candidate delivered; the candidate accepts FUT-014 to FUT-017. + assert accepted, "a guard on the guard: the candidate accepts FUT-014 to FUT-017" for identifier, _ in accepted: entry = text.split(f'\n - id: "{identifier}"', 1)[1].split('\n - id: "', 1)[0] change = re.search(r'\n required_change: "([^"]*)"', entry) diff --git a/tests/regression/test_strategy_configuration_contract.py b/tests/regression/test_strategy_configuration_contract.py index 28955aff..adbfb3b2 100644 --- a/tests/regression/test_strategy_configuration_contract.py +++ b/tests/regression/test_strategy_configuration_contract.py @@ -22,6 +22,10 @@ reads the value (a strategy's parameters are its constructor's, its durable state goes through ``StrategyStateProtocol``, which is exact), and a run's ``configuration_id`` is the same before and after a restore (held below). +Since the v4.2.0 candidate (FUT-002) a strategy that declares its +configuration's type reads it back typed from this same payload -- +``test_typed_strategy_configuration.py`` -- and one that declares nothing reads +back as held here. """ from __future__ import annotations diff --git a/tests/regression/test_typed_strategy_configuration.py b/tests/regression/test_typed_strategy_configuration.py new file mode 100644 index 00000000..a86006d3 --- /dev/null +++ b/tests/regression/test_typed_strategy_configuration.py @@ -0,0 +1,414 @@ +"""A strategy that declares its configuration's type reads it back exactly (v4.1.1, FUT-002). + +``test_strategy_configuration_contract.py`` holds what a configuration reads +back as when nothing declares its type: JSON's reading of the encoder's form. +This holds the other half: a strategy class setting ``configuration_type`` is +configured only with a value of that type, and a restore reads the recorded +value back *as* that type -- equal, and of the same types all the way down -- +through the path a restore takes (``capture``, ``serialize``, ``deserialize``, +``from_primitives``, ``restore``). The payload is unchanged, so a run's record +and identities are what they were. +""" + +from __future__ import annotations + +import enum +import math +import uuid +from collections.abc import Mapping +from dataclasses import dataclass, field, replace +from datetime import UTC, datetime +from decimal import Decimal +from types import MappingProxyType +from typing import Annotated, Any, Literal + +import pytest + +from alphalab.backtesting import BacktestEngine +from alphalab.lifecycle import digest_run +from alphalab.persistence import deserialize, serialize +from alphalab.persistence.exceptions import StateDecodeError +from alphalab.runtime.run_snapshot import RunObjects, capture, from_primitives, restore +from alphalab.runtime.snapshot import RuntimeObjects +from alphalab.strategy import ( + StrategyConfigurationError, + decode_configuration, + require_configuration, +) +from alphalab.strategy.runtime import create_runtime, register_strategy +from alphalab.strategy.supervisor import RuntimeSupervisor +from tests.integration.harness import ScriptedStrategy, backtest_config, dataset_of_quotes + +STRATEGY = "FUT002-STRATEGY" +ASSET = "FUT002-ASSET" + + +class Side(enum.StrEnum): + BUY = "buy" + SELL = "sell" + + +class Mode(enum.Enum): + FAST = 1 + SLOW = 2 + + +class Level(enum.IntEnum): + LOW = 1 + HIGH = 2 + + +@dataclass(frozen=True) +class Window: + fast: int + slow: int + + def __post_init__(self) -> None: + if self.fast >= self.slow: + raise ValueError(f"fast {self.fast} must be below slow {self.slow}") + + +@dataclass(frozen=True) +class Bands: + width: Decimal + windows: tuple[Window, ...] + sides: tuple[Side, ...] + mode: Mode + levels: dict[int, Decimal] + by_level: Mapping[Level, str] + flags: dict[bool, float] + cap: Decimal | None + run_id: uuid.UUID + kind: Literal["mean", "trend"] + weights: list[Annotated[float, "a weight"]] + note: Any + pair: tuple[str, Decimal] + #: Added "later": a payload without it reads back at the default. + tolerance: Decimal = Decimal("0.01") + tags: tuple[str, ...] = field(default_factory=tuple) + + +BANDS = Bands( + width=Decimal("2.50"), + windows=(Window(5, 20), Window(10, 50)), + sides=(Side.BUY, Side.SELL), + mode=Mode.FAST, + levels={1: Decimal("1E+3"), -2: Decimal("-0")}, + by_level=MappingProxyType({Level.LOW: "low", Level.HIGH: "high"}), + flags={True: 1.5, False: -0.0}, + cap=None, + run_id=uuid.UUID(int=42), + kind="mean", + weights=[0.25, 0.75], + note={"free": [1, "two", None, {"three": 3.0}]}, + pair=("XNAS", Decimal("0.10")), + tags=("a", "b"), +) + + +class Typed(ScriptedStrategy): + configuration_type = Bands + + +def _same_types(left: Any, right: Any, where: str = "config") -> None: + """Equal is not enough: every value must also be of the type it was.""" + + assert type(left) is type(right), f"{where}: {type(left).__name__} != {type(right).__name__}" + if isinstance(left, Mapping): + assert list(left) == list(right) or set(left) == set(right), where + for key in left: + matching = next(other for other in right if other == key) + _same_types(key, matching, f"{where} key") + _same_types(left[key], right[key], f"{where}[{key!r}]") + elif isinstance(left, list | tuple): + for index, (a, b) in enumerate(zip(left, right, strict=True)): + _same_types(a, b, f"{where}[{index}]") + elif hasattr(left, "__dataclass_fields__"): + for name in left.__dataclass_fields__: + _same_types(getattr(left, name), getattr(right, name), f"{where}.{name}") + + +def _state(strategy: ScriptedStrategy, configured: Any) -> Any: + runtime = register_strategy(create_runtime(), STRATEGY, strategy) + entry, _ = RuntimeSupervisor.configure(runtime.strategies[STRATEGY], configured, 1.0) + entry, _ = RuntimeSupervisor.initialize(entry, 1.1) + entry, _ = RuntimeSupervisor.subscribe(entry, frozenset({"quotes"}), 1.2) + entry, _ = RuntimeSupervisor.start(entry, 1.3) + return replace(runtime, strategies={STRATEGY: entry}) + + +def _objects(strategy: ScriptedStrategy) -> RunObjects: + config = backtest_config(STRATEGY) + return RunObjects( + pipeline=RuntimeObjects( + sizing_model=config.pipeline.sizing_model, + simulator=config.pipeline.simulator, + strategies={STRATEGY: strategy}, + instruments=config.pipeline.instruments, + ), + fill_policy=config.fill_policy, + ) + + +def _payload(strategy: ScriptedStrategy, configured: Any) -> Any: + state = BacktestEngine.initialize(backtest_config(STRATEGY), _state(strategy, configured)) + return deserialize(serialize(capture(state))) + + +def _restored(payload: Any, strategy: ScriptedStrategy) -> Any: + run = restore(from_primitives(payload), _objects(strategy)) + return run.pipeline.strategy.strategies[STRATEGY].config + + +def _typed() -> Typed: + return Typed(STRATEGY, ASSET, {}) + + +def _config_of(payload: Any) -> Any: + (record,) = payload["pipeline"]["strategy"] + return record["config"] + + +# --------------------------------------------------------------------------- # +# Round trips +# --------------------------------------------------------------------------- # + + +def test_a_declared_configuration_reads_back_equal_and_of_every_type() -> None: + restored = _restored(_payload(_typed(), BANDS), _typed()) + + assert restored == BANDS + _same_types(restored, BANDS) + assert math.copysign(1.0, restored.flags[False]) == -1.0, "the sign of zero" + assert str(restored.levels[1]) == "1E+3", "a Decimal's exponent" + + +@pytest.mark.parametrize( + ("declared", "value"), + [ + (Decimal, Decimal("10.00")), + (Decimal, Decimal("1E+3")), + (Decimal, Decimal("-0")), + (tuple[int, ...], (2, 4)), + (tuple[int, ...], ()), + (tuple[int, tuple[int, int]], (2, (3, 4))), + (dict[int, str], {1: "one", 2: "two"}), + (dict[bool, str], {True: "yes"}), + (dict[float, str], {1.5: "x", -2.0: "y"}), + (Side, Side.BUY), + (Mode, Mode.FAST), + (Level, Level.HIGH), + (dict[Level, int], {Level.LOW: 1}), + (uuid.UUID, uuid.UUID(int=7)), + (Window, Window(2, 4)), + (Decimal | None, None), + (Decimal | None, Decimal("0.5")), + (Literal[1, "x", True], True), + (Any, {"native": [1, 2.5, None, True, "s"]}), + ], + ids=repr, +) +def test_every_value_the_contract_converts_reads_back_exactly_when_declared( + declared: Any, value: Any +) -> None: + class Declared(ScriptedStrategy): + configuration_type = declared + + strategy = Declared(STRATEGY, ASSET, {}) + restored = _restored(_payload(strategy, value), Declared(STRATEGY, ASSET, {})) + + assert restored == value + _same_types(restored, value) + + +def test_the_payload_and_the_runs_identity_do_not_move() -> None: + """Declaring a type changes how a configuration is read, never how it is written.""" + + typed = _payload(_typed(), BANDS) + plain = _payload(ScriptedStrategy(STRATEGY, ASSET, {}), BANDS) + assert _config_of(typed) == _config_of(plain) + + data = dataset_of_quotes(ASSET, [Decimal("100"), Decimal("101"), Decimal("102")]) + + def run(strategy: ScriptedStrategy) -> Any: + return BacktestEngine.run( + backtest_config(STRATEGY), data, _state(strategy, BANDS), _context + ) + + # Two classes of one name, one declaring a type and one not: one record. + class Undeclared(ScriptedStrategy): + pass + + Undeclared.__name__ = Undeclared.__qualname__ = Typed.__name__ + declared_digest = digest_run(run(_typed())) + undeclared_digest = digest_run(run(Undeclared(STRATEGY, ASSET, {}))) + assert declared_digest.result_id == undeclared_digest.result_id + assert declared_digest.configuration_id == undeclared_digest.configuration_id + + +def _context(strategy_id: str) -> Any: + from tests.integration.harness import context_factory + + return context_factory(strategy_id) + + +# --------------------------------------------------------------------------- # +# Refused at configure, naming the field +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + ("value", "named"), + [ + (replace(BANDS, width=2.5), r"config\.width: expected Decimal, found float"), # type: ignore[arg-type] + (replace(BANDS, windows=[Window(1, 2)]), r"config\.windows: expected a tuple"), # type: ignore[arg-type] + (replace(BANDS, sides=("buy",)), r"config\.sides\[0\]: expected a Side member"), # type: ignore[arg-type] + (replace(BANDS, weights=[1]), r"config\.weights\[0\]: expected float, found int"), + (replace(BANDS, cap=Decimal("NaN")), r"config\.cap: Decimal\('NaN'\) is not finite"), + (replace(BANDS, kind="other"), r"config\.kind: expected one of"), # type: ignore[arg-type] + (replace(BANDS, levels={"1": Decimal("1")}), r"config\.levels key: expected int"), # type: ignore[dict-item] + (replace(BANDS, note={"d": Decimal("1")}), r"config\.note\['d'\].*declare its type"), + (replace(BANDS, note=(1, 2)), r"config\.note.*declare its type"), + (replace(BANDS, pair=("XNAS",)), r"config\.pair: expected 2 items"), # type: ignore[arg-type] + ({"width": Decimal("1")}, r"config: expected a Bands, found dict"), + (replace(BANDS, windows=(Window(True, 4),)), r"\.fast: expected int, found bool"), + (replace(BANDS, pair=(Side.BUY, Decimal("1"))), r"pair\[0\]: expected str, found Side"), + ], + ids=["float-for-decimal", "list-for-tuple", "text-for-enum", "int-for-float", "nan", + "literal", "key-type", "decimal-in-any", "tuple-in-any", "tuple-length", "dict", + "bool-for-int", "str-subclass-for-str"], +) # fmt: skip +def test_a_value_not_of_the_declared_type_is_refused_at_configure(value: Any, named: str) -> None: + runtime = register_strategy(create_runtime(), STRATEGY, _typed()) + with pytest.raises(StrategyConfigurationError, match=named): + RuntimeSupervisor.configure(runtime.strategies[STRATEGY], value, 1.0) + + +class Printed(enum.Enum): + ONE = 1 + + def __str__(self) -> str: + return "one" + + +@dataclass(frozen=True) +class Derived: + base: int + double: int = field(init=False) + + def __post_init__(self) -> None: + object.__setattr__(self, "double", self.base * 2) + + +class Plain: + pass + + +@pytest.mark.parametrize( + ("declared", "named"), + [ + (set[int], "cannot be declared"), + (frozenset[str], "cannot be declared"), + (datetime, "cannot be declared"), + (int | str, "union of two types"), + (dict[Decimal, int], "keyed by"), + (dict[uuid.UUID, int], "keyed by"), + (dict[Mode, int], "keyed by"), + (Plain, "cannot be declared"), + (Derived, "not an __init__ field"), + (Printed, "redefines how its members print"), + (Literal[1.5], "is not a bool, int or str"), + ], + ids=repr, +) +def test_a_type_whose_values_cannot_be_read_back_is_refused(declared: Any, named: str) -> None: + with pytest.raises(StrategyConfigurationError, match=named): + require_configuration(None, declared) + + +def test_a_naive_and_an_aware_datetime_are_both_refused_not_coerced() -> None: + for value in (datetime(2026, 1, 1), datetime(2026, 1, 1, tzinfo=UTC)): + with pytest.raises(StrategyConfigurationError, match="cannot be declared"): + require_configuration(value, datetime) + + +# --------------------------------------------------------------------------- # +# Read back: defaults, constraints, and what is refused +# --------------------------------------------------------------------------- # + + +def test_a_field_added_since_the_payload_was_written_reads_back_at_its_default() -> None: + payload = _payload(_typed(), BANDS) + recorded = _config_of(payload) + del recorded["tolerance"], recorded["tags"] + + restored = _restored(payload, _typed()) + + assert restored == replace(BANDS, tolerance=Decimal("0.01"), tags=()) + + +@pytest.mark.parametrize( + ("change", "named"), + [ + (lambda c: c.update(width=2.5), r"width: expected a Decimal's text"), + (lambda c: c.update(width="2.5000000000000000000000000001x"), r"is not a Decimal"), + (lambda c: c.update(width="NaN"), r"not the text of a finite Decimal"), + (lambda c: c.update(mode="Other.FAST"), r"is not a Mode member"), + (lambda c: c.update(run_id="not-a-uuid"), r"is not a UUID"), + (lambda c: c.update(windows=[{"fast": 9, "slow": 3}]), r"Window refused the recorded"), + (lambda c: c.update(windows=[{"fast": 1, "slow": 2, "extra": 0}]), r"no field 'extra'"), + (lambda c: c.pop("width"), r"width: missing"), + (lambda c: c.update(levels={"01": "1"}), r"'01' is not the text an int key"), + (lambda c: c.update(weights=[1, 0.5]), r"weights\[0\]: expected float, found int"), + (lambda c: c.update(width="+2.50"), r"'\+2.50' is not the text of a finite Decimal"), + (lambda c: c.update(mode="FAST"), r"'FAST' is not a Mode member"), + ], + ids=["number-for-decimal", "garbled", "nan", "enum", "uuid", "constraint", "unknown-field", + "missing", "noncanonical-key", "int-for-float", "noncanonical-decimal", "bare-name"], +) # fmt: skip +def test_a_recorded_value_not_of_the_declared_type_is_refused_on_restore( + change: Any, named: str +) -> None: + payload = _payload(_typed(), BANDS) + change(_config_of(payload)) + with pytest.raises(StateDecodeError, match=rf"strategy {STRATEGY!r}.*{named}"): + _restored(payload, _typed()) + + +def test_a_strategy_that_declares_nothing_reads_back_as_before() -> None: + plain = ScriptedStrategy(STRATEGY, ASSET, {}) + restored = _restored(_payload(plain, BANDS), ScriptedStrategy(STRATEGY, ASSET, {})) + assert restored == _config_of(_payload(plain, BANDS)) + assert restored["width"] == "2.50" and restored["sides"] == ["buy", "sell"] + + +def test_decoding_is_the_inverse_of_the_encoder_for_the_declared_type() -> None: + encoded = deserialize(serialize(BANDS)) + assert decode_configuration(encoded, Bands) == BANDS + with pytest.raises(StrategyConfigurationError, match="expected an object of Bands"): + decode_configuration([encoded], Bands) + + +def test_a_paused_replay_resumes_with_the_declared_configuration() -> None: + from alphalab.backtesting import PausedReplay, ReplayBacktest + from alphalab.backtesting.replay_snapshot import capture as capture_replay + from alphalab.backtesting.replay_snapshot import from_primitives as replay_from_primitives + from alphalab.backtesting.replay_snapshot import restore as restore_replay + + data = dataset_of_quotes(ASSET, [Decimal("100"), Decimal("101"), Decimal("102")]) + paused = ReplayBacktest.run( + backtest_config(STRATEGY), + data, + _state(_typed(), BANDS), + _context, + pause_when=lambda progress: progress.records_replayed == 2, + ) + assert isinstance(paused, PausedReplay) + payload = deserialize(serialize(capture_replay(paused))) + restored = restore_replay(replay_from_primitives(payload), _objects(_typed()), data) + config = restored.run.pipeline.strategy.strategies[STRATEGY].config + assert config == BANDS + _same_types(config, BANDS) + resumed = ReplayBacktest.resume(restored, _context) + whole = ReplayBacktest.run(backtest_config(STRATEGY), data, _state(_typed(), BANDS), _context) + assert digest_run(resumed.backtest).result_id == digest_run(whole.backtest).result_id From 5aca12763564de25c68a11efc238ea77fd5d840b Mon Sep 17 00:00:00 2001 From: VarunSingh022 Date: Sat, 31 Oct 2026 09:35:35 +0530 Subject: [PATCH 04/56] chore(release): make 4.2.0 the development version The maintainer approved v4.2.0 as the development and release target. This applies the readiness report's versioning checklist, except anything that publishes: no tag is created or moved, no release or upload is made, `main` is not touched, and the v4.1.0 tag and its artifacts are unchanged. What changed - alphalab/common/_version.py, the one declaration, is now 4.2.0, and test_package_metadata's pin follows it. - CHANGELOG: [Unreleased] becomes [4.2.0], marked unreleased and not yet published or tagged. The changelog guard showed the section lacked three changed bindings, and the rows are added: - BrokerAssetClass, the AssetType enum, with BOND and INDEX; - InstrumentRegistryRecord, with last_trade_at and terms; - InstrumentRegistrySnapshot, whose schema_version default goes 3 -> 4. - The version markers in README, docs/README, ARCHITECTURE and nowandfuture now say 4.2.0, and README states 4.2.0 is unpublished and the published tag is v4.1.0. - docs/INTEGRATION.md's pins follow the release-prep convention, with a note that the 4.2.0 references resolve only once it is published. Its claim about which payloads were frozen stays v4.1.0's, which is what is true. - docs/api/history/4.2.0.json is the release's own manifest, which the release preflight requires, with its README row. The public API manifest is regenerated (release 4.2.0, 2,683 exports) and the certificate re-certified: only the release label, the engine string and the source and manifest digests moved, and no result_id. - .github/workflows/preflight.yml's push route and RELEASE_VERSION now name the 4.2.0 candidate branch, work/v4.2.0-release, and 4.2.0. This development branch was named before the number was chosen; its pull request can be preflighted by its label. The preflight tests' identity cases follow the same convention. actionlint (with shellcheck) is clean. - ROADMAP and FUTURE_READINESS_V4_1_1 record the decision. The stale-metadata failure (test_package_exposes_version) was the dev venv's editable install, made at 4.0.0 and never refreshed. It was reinstalled locally with --no-deps -e; nothing in the repository changed for it. Validation: the full suite, dev venv, -W error, gives 10,118 passed, 0 failed, 0 skipped. ruff, format and git diff --check are clean. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/preflight.yml | 17 +- CHANGELOG.md | 4 +- README.md | 6 +- ROADMAP.md | 4 +- alphalab/common/_version.py | 2 +- docs/ARCHITECTURE.md | 8 +- docs/INTEGRATION.md | 56 +- docs/README.md | 2 +- docs/api/PUBLIC_API.md | 2 +- docs/api/history/4.2.0.json | 3076 ++++++++++++++++++++ docs/api/history/README.md | 1 + docs/api/public_api.json | 2 +- docs/audit/FUTURE_READINESS_V4_1_1.md | 31 +- docs/audit/RELEASE_CERTIFICATION.md | 8 +- docs/audit/release_certification.json | 8 +- nowandfuture.md | 4 +- tests/regression/test_release_preflight.py | 20 +- tests/unit/test_package_metadata.py | 2 +- 18 files changed, 3170 insertions(+), 83 deletions(-) create mode 100644 docs/api/history/4.2.0.json diff --git a/.github/workflows/preflight.yml b/.github/workflows/preflight.yml index 8108309b..4be81857 100644 --- a/.github/workflows/preflight.yml +++ b/.github/workflows/preflight.yml @@ -6,12 +6,15 @@ name: Release Preflight # CodeQL and benchmark gates, while there is still nothing to take back. # # Which commit it validates, and how it refuses to validate the wrong one: -# * A push to the v4.1.0 candidate branch, work/v4.1.0-release-hardening: the +# * A push to the v4.2.0 candidate branch, work/v4.2.0-release: the # commit pushed, and only that -- the run's commit must be the pushed tip # (github.event.after), the ref must be that branch, and a deletion is # refused: when a branch is deleted GitHub reports the DEFAULT branch's # commit for the run, which is how a push trigger could validate `main`. -# This route needs nothing on `main`. Retarget or remove it after v4.1.0. +# This route needs nothing on `main`. Retarget or remove it after v4.2.0. +# (v4.2.0's development branch is work/v4.1.1-future-readiness, named before +# the number was chosen; a candidate pushed for release goes to +# work/v4.2.0-release, or is validated by labelling its pull request.) # * Run manually ("Run workflow"): the commit of the branch or tag chosen under # "Use workflow from". The `candidate` input must repeat that name, so a run # left on `main` by mistake fails in its first step instead of validating @@ -34,13 +37,13 @@ on: required: true type: string expected_version: - description: "The version this release will be, e.g. 4.1.0 (its tag is v)" + description: "The version this release will be, e.g. 4.2.0 (its tag is v)" required: true type: string # The candidate branch alone; never main. The guard below names it again. push: branches: - - work/v4.1.0-release-hardening + - work/v4.2.0-release pull_request: branches: - main @@ -78,7 +81,7 @@ jobs: REF_NAME: ${{ github.ref_name }} RUN_SHA: ${{ github.sha }} # The branch on.push.branches names; a test holds the two together. - PUSH_BRANCH: work/v4.1.0-release-hardening + PUSH_BRANCH: work/v4.2.0-release PUSHED_SHA: ${{ github.event.after }} PUSH_DELETED: ${{ github.event.deleted }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} @@ -138,7 +141,7 @@ jobs: python-version: "3.12" # The candidate must BE the release, not merely agree with itself: a tree - # whose every reference says 4.1.1 fails here. A manual run names the + # whose every reference says 4.2.1 fails here. A manual run names the # release in expected_version; a push or a labelled pull request is the # candidate branch's release, RELEASE_VERSION (a test holds it to the # package's version and to the push branch's name). @@ -147,7 +150,7 @@ jobs: env: EVENT: ${{ github.event_name }} DISPATCHED_VERSION: ${{ inputs.expected_version }} - RELEASE_VERSION: "4.1.0" + RELEASE_VERSION: "4.2.0" run: | set -euo pipefail if [ "${EVENT}" = "workflow_dispatch" ]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f8b3c06..840af247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ changed. The current state of the project is in `README.md`, `ROADMAP.md` and --- -# [Unreleased] - v4.1.1 candidate (release number pending the maintainer's decision; recommended 4.2.0) +# [4.2.0] - unreleased (the development target the maintainer approved; not yet published or tagged) **A researcher-defined strategy over several assets, datasets, sentiment, macro releases and fitted models -- every timed input applied at its instant on every @@ -186,6 +186,8 @@ IDs are in brackets. | `alphalab.runtime.ExecutionAssumptions`, `execution_assumptions` | field `queue_model` and trailing parameter `queue_model`, `None` by default; property `passive_fills`; `settings()` adds `execution.queue_model` only when a model is declared | | `alphalab.runtime.execution_pipeline.ExecutionRouting` | member `ALGORITHMIC`; `ExecutionMode.works_children` | | `alphalab.core.enums.AssetType` | members `BOND` and `INDEX` | +| `alphalab.broker.BrokerAssetClass` | the same enum as `AssetType`, so members `BOND` and `INDEX` | +| `alphalab.instrument.snapshot.InstrumentRegistryRecord`, `InstrumentRegistrySnapshot` | the record's fields `last_trade_at` and `terms`, `None` by default, and the snapshot's `schema_version` default 3 -> 4 (instrument schema 4; a version-3 payload upgrades) -- a snapshot a caller obtains from `capture` or `from_primitives`, not builds | | `alphalab.instrument.record.InstrumentRecord` | fields `last_trade_at` and `terms`, `None` by default; `InstrumentRegistry.expiring`, a derived index | | `alphalab.lifecycle.manifest_for_run`, `canonical_manifest_key`, `derive_manifest_id` | keyword or trailing `schedule` / `schedule_digest` (`None`) and `dataset_parts` (empty) by default, `execution` / `execution_plan_id` and `queue_model` (`None`); `ReproducibilityManifest.schedule_digest`, `dataset_parts`, `execution_plan_id` and `queue_model` | | `alphalab.portfolio.engine.PortfolioEngine.apply_fill` | keyword-only `final_settlement`, `False` by default | diff --git a/README.md b/README.md index 2d10b1a4..e28319c0 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ **Deterministic • Immutable • Fully typed • Zero runtime dependencies** [![Python](https://img.shields.io/badge/Python-3.12+-3776AB?logo=python&logoColor=white)]() -[![Version](https://img.shields.io/badge/Version-4.1.0-blue)]() +[![Version](https://img.shields.io/badge/Version-4.2.0-blue)]() [![License](https://img.shields.io/badge/License-MIT-green.svg)]() [![Typing](https://img.shields.io/badge/MyPy-Strict-blue)]() [![Style](https://img.shields.io/badge/Ruff-Clean-red)]() @@ -45,12 +45,12 @@ It exists to answer the questions that decide whether a backtest means anything: # Release Status -**Current Release:** **v4.1.0 — a replay that pauses and resumes durably to the identical result, CPython 3.12 to 3.14 on Linux and macOS, run identities that no longer depend on the host, and release verification fixed at its root -- additive on the v4.0 contract** +**Current Release:** **v4.2.0 (development target, not yet published; the published tag is v4.1.0) — every timed input on every driver, several datasets in one run, contracts declared and carried through exercise, assignment, expiry and delivery, execution algorithms and a queue model on the historical drivers, typed strategy configuration -- additive on the v4.0 contract** | Metric | Status | |---------|--------| | Python | CPython 3.12, 3.13, 3.14 (Linux, macOS) | -| Version | 4.1.0 | +| Version | 4.2.0 | | Runtime dependencies | **None** (standard library only) | | Tests | **9,672 passing, 0 skipped, 0 warnings** (each of CPython 3.12, 3.13, 3.14) | | Static typing | **Strict mypy**, repository-wide | diff --git a/ROADMAP.md b/ROADMAP.md index d67056fe..b6d13a5c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,7 +159,7 @@ names what the release established and the ADR that records the decision. - **The contract an application builds on, checked against the artifact**: `tests/consumer_contract.py` runs inside every installed wheel and sdist. -## v4.2.0 candidate (the v4.1.1 future-readiness program; release number pending) +## v4.2.0 (in development: the v4.1.1 future-readiness program, its number approved by the maintainer; not yet published) - **One schedule for every timed input** (FUT-004): `alphalab.backtesting.schedule` -- rates, funding, cash flows, per-unit dividends and coupons, perpetual funding @@ -554,7 +554,7 @@ holds this list to the ledger: an entry may be left for later only here. The entries below were proposed by the v4.1.1 future-readiness audit (`docs/audit/STRATEGY_RESEARCH_TO_PRODUCTION_GAPS.md`); accepting each, and its horizon, is the maintainer's decision. The candidate itself delivered the rest -of what the audit proposed (section *v4.2.0 candidate*, above). +of what the audit proposed (section *v4.2.0*, above). ## FUT-014 — a strategy that instructs an exercise from its hooks (target v4.3) diff --git a/alphalab/common/_version.py b/alphalab/common/_version.py index b57600e1..3183433e 100644 --- a/alphalab/common/_version.py +++ b/alphalab/common/_version.py @@ -9,4 +9,4 @@ recorded that. """ -__version__ = "4.1.0" +__version__ = "4.2.0" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 92c2f83b..ed0a92de 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,7 +4,7 @@ AlphaLab is an institutional-grade quantitative research and algorithmic trading platform built around deterministic execution, immutable state, and event-driven architecture. -Every subsystem follows the same engineering principles (immutable state, pure functional engines, deterministic execution). They are designed to compose through well-defined interfaces, but only `alphalab.runtime.ExecutionPipeline`, the `alphalab.runtime.run.RunEngine` that owns a run over it, and the drivers that feed it — `alphalab.runtime.session`, `alphalab.backtesting`, `alphalab.backtesting.replay` and `alphalab.runtime.live` — together with `alphalab.lifecycle`, which v2.16 joined to it, actually wire a group of them together. See the **Implementation Status (v4.1)** section below. +Every subsystem follows the same engineering principles (immutable state, pure functional engines, deterministic execution). They are designed to compose through well-defined interfaces, but only `alphalab.runtime.ExecutionPipeline`, the `alphalab.runtime.run.RunEngine` that owns a run over it, and the drivers that feed it — `alphalab.runtime.session`, `alphalab.backtesting`, `alphalab.backtesting.replay` and `alphalab.runtime.live` — together with `alphalab.lifecycle`, which v2.16 joined to it, actually wire a group of them together. See the **Implementation Status (v4.2)** section below. > **How to read this document.** The **Implementation Status** section and > everything up to *Known boundaries* describe what is **built**. From @@ -19,7 +19,7 @@ Every component—from market data ingestion to production deployment—is desig --- -# Implementation Status (v4.1) +# Implementation Status (v4.2) Most of this document describes the **target** architecture. This section states what is actually built so the two are not confused. @@ -5327,8 +5327,8 @@ The architecture documented here serves as the reference implementation for all ``` Architecture Specification -Version: v4.1.0 -Status: Implementation Status (v4.1) describes what is built and is authoritative. +Version: v4.2.0 +Status: Implementation Status (v4.2) describes what is built and is authoritative. From "Design Goals" onward the document describes the architectural model and long-term target. Both halves name only packages that exist. ``` \ No newline at end of file diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 393d59ba..4491d93b 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -1,7 +1,7 @@ -# Depending on AlphaLab 4.1 from an application +# Depending on AlphaLab 4.2 from an application How a host application -- iluvtrade is the one this release was prepared for -- -installs AlphaLab 4.1.0 and what it may rely on. Every statement here is held by +installs AlphaLab 4.2.0 and what it may rely on. Every statement here is held by a test, a release gate or the certificate; where something is *not* established, this page says so. The checklist at the end is the order to wire it in. @@ -11,18 +11,23 @@ this page says so. The checklist at the end is the order to wire it in. | | | | --- | --- | -| Distribution | `alphalab` **4.1.0**: a wheel `alphalab-4.1.0-py3-none-any.whl` (pure Python) and an sdist `alphalab-4.1.0.tar.gz` | -| Python | `Requires-Python: >=3.12`. **Tested on CPython 3.12, 3.13 and 3.14** -- every release gate on each, in CI and in the 4.1.0 verification (ledger LIM-007). Other implementations are not tested | +| Distribution | `alphalab` **4.2.0**: a wheel `alphalab-4.2.0-py3-none-any.whl` (pure Python) and an sdist `alphalab-4.2.0.tar.gz` | +| Python | `Requires-Python: >=3.12`. **Tested on CPython 3.12, 3.13 and 3.14** -- every release gate on each, in CI and in the 4.2.0 verification (ledger LIM-007). Other implementations are not tested | | Operating systems | **Linux and macOS**, each a required CI cell under all three interpreters. Windows has never run a gate and is not claimed (ledger BDY-027) | | Runtime dependencies | **None.** The standard library is the whole of it. The `dev` extra is the lint, type-check, test and build toolchain and is not needed to use the library | | Type information | `py.typed` ships in the wheel; the package is checked with `mypy --strict` | | Network | None at import or on the research, backtest or replay paths: the examples the release gates run from each installed artifact run with every socket operation refused. Live connectivity is the application's: AlphaLab defines the broker and market-data contracts an adapter implements, and holds no vendor client and no credential | +**4.2.0 is the development target and is not yet published.** Until its tag +and release exist, the download and `@v4.2.0` references below resolve to +nothing; the published tag is v4.1.0, whose own `docs/INTEGRATION.md` describes +it. + `tests/installed_smoke.py` is what the release gates -- CI on every push, Release Preflight before publication and Release Validation after it -- run against each built distribution in a fresh environment, from outside any checkout: `pip check` finds nothing broken, no runtime requirement is declared, every module -imports without a warning, the version is 4.1.0, `py.typed` is present, every +imports without a warning, the version is 4.2.0, `py.typed` is present, every public name is the one the manifest records, examples `11`, `70` and `71` run end to end with every socket operation refused, and `tests/consumer_contract.py` -- the contract below, exercised through public names, a replay paused in one @@ -30,20 +35,20 @@ process and resumed in another included -- passes against the artifact. ## How to install it -AlphaLab 4.1.0 is **not published on PyPI**, and no project of that name there -belongs to it. Do not write a bare `alphalab==4.1.0` against a public index: if +AlphaLab 4.2.0 is **not published on PyPI**, and no project of that name there +belongs to it. Do not write a bare `alphalab==4.2.0` against a public index: if somebody registers the name, that requirement installs their package. Install the released artifact by a direct reference instead, and pin its hash. -From the release's wheel (recommended). Once the `v4.1.0` release is published -on GitHub, download its `SHA256SUMS-4.1.0` beside the wheel and write the pinned +From the release's wheel (recommended). Once the `v4.2.0` release is published +on GitHub, download its `SHA256SUMS-4.2.0` beside the wheel and write the pinned requirement from it -- no value is typed by hand: ```bash -RELEASE="https://github.com/VarunSingh022/AlphaLab/releases/download/v4.1.0" -curl -fsSLO "${RELEASE}/SHA256SUMS-4.1.0" -WHEEL_SHA256="$(grep ' alphalab-4.1.0-py3-none-any.whl$' SHA256SUMS-4.1.0 | cut -d ' ' -f 1)" -printf 'alphalab @ %s/alphalab-4.1.0-py3-none-any.whl --hash=sha256:%s\n' "${RELEASE}" "${WHEEL_SHA256}" > requirements-alphalab.txt +RELEASE="https://github.com/VarunSingh022/AlphaLab/releases/download/v4.2.0" +curl -fsSLO "${RELEASE}/SHA256SUMS-4.2.0" +WHEEL_SHA256="$(grep ' alphalab-4.2.0-py3-none-any.whl$' SHA256SUMS-4.2.0 | cut -d ' ' -f 1)" +printf 'alphalab @ %s/alphalab-4.2.0-py3-none-any.whl --hash=sha256:%s\n' "${RELEASE}" "${WHEEL_SHA256}" > requirements-alphalab.txt python -m pip install --require-hashes -r requirements-alphalab.txt ``` @@ -51,7 +56,7 @@ From the tag, building the sdist locally (needs network access to fetch `hatchling` for the build): ```bash -python -m pip install "alphalab @ git+https://github.com/VarunSingh022/AlphaLab@v4.1.0" +python -m pip install "alphalab @ git+https://github.com/VarunSingh022/AlphaLab@v4.2.0" ``` An editable install or a checkout on `sys.path` is for working on AlphaLab, not @@ -67,15 +72,16 @@ python -m pip check python -c "import alphalab, alphalab.api; print(alphalab.__version__, alphalab.__file__)" ``` -The version must be `4.1.0` and the path must be inside the environment's +The version must be `4.2.0` and the path must be inside the environment's `site-packages`. ## Which version range to declare `docs/api/PUBLIC_API.md` states the policy: from 4.0, a minor release adds and corrects and does not break; a breaking change waits for 5.0 with an ADR and a -migration. An application using replay pause and resume needs 4.1, so it can -accept `>=4.1,<5`; pinning `4.1.0` exactly, with its hash, is what makes a +migration. An application using replay pause and resume needs 4.1, and one using a +schedule, an execution plan, a queue model, declared contract terms or a typed +configuration needs 4.2, so it can accept `>=4.2,<5`; pinning `4.2.0` exactly, with its hash, is what makes a deployment reproducible, and is what this release recommends. ## What the application may rely on @@ -144,9 +150,9 @@ record. **Persistence.** A run stopped, serialized, read back and continued finishes byte-identical to one never stopped (certificate REP-2), and so does a paused replay (FUT-001). Every payload v3.9.0, v3.11.0, v3.12.0, v3.13.0 and v4.1.0 wrote -and froze is read by 4.1.0 (REP-3); 4.1.0 writes the run and pipeline schemas +and froze is read by 4.2.0 (REP-3); 4.2.0 writes the run and pipeline schemas 4.0.0 wrote (5 and 8), so it reads 4.0.0's payloads as 4.0.0 does. A later 4.x -reading what 4.1.0 writes is the stability policy's commitment (a persisted +reading what 4.2.0 writes is the stability policy's commitment (a persisted format moves only by a schema step that reads every older payload). A restore must be given the same sizing model, simulator and fill policy, configured the same way, as the run was captured with; it refuses anything else and names both @@ -182,9 +188,9 @@ lists each boundary and limitation with its reason. In order; each step names what holds it. -1. **Pin the artifact.** Depend on `alphalab-4.1.0-py3-none-any.whl` by direct - reference with its SHA-256 from `SHA256SUMS-4.1.0` (above), never a bare - `alphalab==4.1.0`. Use CPython 3.12, 3.13 or 3.14 on Linux or macOS. +1. **Pin the artifact.** Depend on `alphalab-4.2.0-py3-none-any.whl` by direct + reference with its SHA-256 from `SHA256SUMS-4.2.0` (above), never a bare + `alphalab==4.2.0`. Use CPython 3.12, 3.13 or 3.14 on Linux or macOS. 2. **Verify the install** in iluvtrade's own environment: `pip check`, then the version and import location (above). Then run the release's consumer contract against the installed package, from the release's sdist unpacked @@ -194,9 +200,9 @@ In order; each step names what holds it. ```bash SCRATCH="$(mktemp -d)" - curl -fsSL -o "${SCRATCH}/alphalab-4.1.0.tar.gz" "https://github.com/VarunSingh022/AlphaLab/releases/download/v4.1.0/alphalab-4.1.0.tar.gz" - tar -xzf "${SCRATCH}/alphalab-4.1.0.tar.gz" -C "${SCRATCH}" - (cd "${SCRATCH}" && python -W error "${SCRATCH}/alphalab-4.1.0/tests/consumer_contract.py" 4.1.0) + curl -fsSL -o "${SCRATCH}/alphalab-4.2.0.tar.gz" "https://github.com/VarunSingh022/AlphaLab/releases/download/v4.2.0/alphalab-4.2.0.tar.gz" + tar -xzf "${SCRATCH}/alphalab-4.2.0.tar.gz" -C "${SCRATCH}" + (cd "${SCRATCH}" && python -W error "${SCRATCH}/alphalab-4.2.0/tests/consumer_contract.py" 4.2.0) ``` 3. **Import public names only** -- those in `docs/api/public_api.json`. Anything else may change in a patch release. diff --git a/docs/README.md b/docs/README.md index d134c867..80326d68 100644 --- a/docs/README.md +++ b/docs/README.md @@ -314,7 +314,7 @@ These principles are applied consistently across every module. # Version ``` -v4.1.0 +v4.2.0 ``` *(This block read `v2.5.0` from v2.5 through v2.16 -- twelve releases that shipped diff --git a/docs/api/PUBLIC_API.md b/docs/api/PUBLIC_API.md index 01ae3494..769ee415 100644 --- a/docs/api/PUBLIC_API.md +++ b/docs/api/PUBLIC_API.md @@ -23,7 +23,7 @@ durability table. | [`generate_public_api.py`](generate_public_api.py) | Writes the manifest from the package. Keeps the reasons already written; leaves a new shared name's reason empty | | `tests/regression/test_public_api_manifest.py` | Fails on any export added, removed or rebound, on a shared name without a reason, and on a manifest for another release | -At v4.1.0 the manifest records 44 packages and 12 documented modules, 2,683 exports and 35 shared names +At v4.2.0 the manifest records 44 packages and 12 documented modules, 2,683 exports and 35 shared names (`test_public_api_manifest.py` holds this sentence to the manifest). ## Stability from v4.0 diff --git a/docs/api/history/4.2.0.json b/docs/api/history/4.2.0.json new file mode 100644 index 00000000..8feaff60 --- /dev/null +++ b/docs/api/history/4.2.0.json @@ -0,0 +1,3076 @@ +{ + "release": "4.2.0", + "rule": "One name, one contract: a name two packages export is the same object in both, or it is listed under shared_names with the reason the two are deliberately different.", + "packages": { + "alphalab": { + "__version__": "value builtins.str" + }, + "alphalab.allocation": { + "AccountAllocation": "class alphalab.allocation.capital.AccountAllocation(account: 'CapitalAccount', requested: 'Decimal', allocated: 'Decimal', unallocated: 'Decimal', scale: 'Decimal | None', base_free: 'Decimal') -> None", + "AllocationCompleted": "class alphalab.allocation.events.AllocationCompleted(event_id: str, timestamp: float, num_orders_generated: int, total_notional: decimal.Decimal) -> None", + "AllocationConstraints": "class alphalab.allocation.constraints.AllocationConstraints(max_weight_per_asset: decimal.Decimal = Decimal('1.0'), min_weight_per_asset: decimal.Decimal = Decimal('-1.0'), allow_shorting: bool = True, enforce_integer_quantities: bool = False) -> None", + "AllocationEngine": "class alphalab.allocation.engine.AllocationEngine()", + "AllocationError": "class alphalab.allocation.exceptions.AllocationError", + "AllocationEvent": "class alphalab.allocation.events.AllocationEvent(event_id: str, timestamp: float) -> None", + "AllocationOptimizer": "class alphalab.allocation.optimizer.AllocationOptimizer(*args, **kwargs)", + "AllocationRejected": "class alphalab.allocation.events.AllocationRejected(event_id: str, timestamp: float, reason: str) -> None", + "AllocationRule": "alias AllocationRule", + "AllocationStarted": "class alphalab.allocation.events.AllocationStarted(event_id: str, timestamp: float, num_intents: int) -> None", + "AllocationState": "class alphalab.allocation.state.AllocationState(budget: alphalab.allocation.budget.CapitalBudget, history: alphalab.common.append_log.AppendOnlyLog[alphalab.core.order_request.OrderRequest] = , events: alphalab.common.append_log.AppendOnlyLog[alphalab.allocation.events.AllocationEvent] = , notional_allocated: decimal.Decimal = Decimal('0.00'), reservations: alphalab.common.persistent_map.PersistentMap[str, decimal.Decimal] = , contributions: alphalab.common.persistent_map.PersistentMap[str, tuple[alphalab.core.contribution.StrategyContribution, ...]] = , strategy_positions: alphalab.common.persistent_map.PersistentMap[str, alphalab.common.persistent_map.PersistentMap[str, decimal.Decimal]] | None = , strategy_capital: alphalab.common.persistent_map.PersistentMap[str, alphalab.allocation.ceilings.StrategyCapital] = ) -> None", + "AllocationValidationError": "class alphalab.allocation.exceptions.AllocationValidationError", + "BudgetExceeded": "class alphalab.allocation.events.BudgetExceeded(event_id: str, timestamp: float, reason: str, requested_notional: decimal.Decimal, available_budget: decimal.Decimal) -> None", + "BudgetExceededError": "class alphalab.allocation.exceptions.BudgetExceededError", + "CAPITAL_ALLOCATION_SCHEME": "value builtins.str", + "CAPITAL_PLAN_SCHEME": "value builtins.str", + "CapitalAccount": "class alphalab.allocation.capital.CapitalAccount(account_id: 'str', broker_id: 'str', currency: 'str', available: 'Decimal', reserved: 'Decimal') -> None", + "CapitalAllocationPlan": "class alphalab.allocation.capital.CapitalAllocationPlan(name: 'str', base_currency: 'str', as_of: 'float', accounts: 'tuple[CapitalAccount, ...]', placements: 'tuple[CapitalPlacement, ...]', rule: 'AllocationRule', limits: 'tuple[CapitalLimit, ...]', oversubscription: 'OversubscriptionRule', granularity: 'Decimal') -> None", + "CapitalAllocationResult": "class alphalab.allocation.capital.CapitalAllocationResult(plan_id: 'str', status: 'CapitalAllocationStatus', base_currency: 'str', total_free: 'Decimal', placements: 'tuple[PlacementAllocation, ...] | None', accounts: 'tuple[AccountAllocation, ...]', dimensions: 'tuple[DimensionAllocation, ...]', checks: 'tuple[LimitCheck, ...]', reasons: 'tuple[str, ...]', conversions: 'tuple[CapitalConversion, ...]') -> None", + "CapitalAllocationStatus": "enum alphalab.allocation.capital.CapitalAllocationStatus[ALLOCATED, REFUSED]", + "CapitalBudget": "class alphalab.allocation.budget.CapitalBudget(global_capital: decimal.Decimal, maximum_exposure: decimal.Decimal, cash_buffer: decimal.Decimal = Decimal('0.00'), strategy_budgets: collections.abc.Mapping[str, decimal.Decimal] = , currency: str = '', enforce_strategy_budgets: bool = False) -> None", + "CapitalConversion": "class alphalab.allocation.capital.CapitalConversion(amount: 'Decimal', base: 'str', quote: 'str', rate: 'Decimal', rate_as_of: 'float', source: 'str', derived: 'bool', converted: 'Decimal') -> None", + "CapitalDimension": "enum alphalab.allocation.capital.CapitalDimension[STRATEGY, MARKET, BROKER, ACCOUNT, CURRENCY]", + "CapitalLimit": "class alphalab.allocation.capital.CapitalLimit(dimension: 'CapitalDimension', bucket: 'str', maximum_share: 'Decimal | None', minimum_share: 'Decimal | None') -> None", + "CapitalPlacement": "class alphalab.allocation.capital.CapitalPlacement(strategy_id: 'str', market: 'str', account_id: 'str') -> None", + "ConversionRecord": "class alphalab.common.currency.ConversionRecord(*args, **kwargs)", + "CurrencyConverter": "class alphalab.common.currency.CurrencyConverter(*args, **kwargs)", + "DimensionAllocation": "class alphalab.allocation.capital.DimensionAllocation(dimension: 'CapitalDimension', amounts: 'Mapping[str, Decimal]', shares: 'Mapping[str, Decimal]', native: 'Mapping[str, Decimal] | None') -> None", + "EqualWeightSizing": "class alphalab.allocation.sizing.EqualWeightSizing(num_assets: int) -> None", + "EqualWeights": "class alphalab.allocation.capital.EqualWeights(fraction: 'Decimal') -> None", + "FixedAmounts": "class alphalab.allocation.capital.FixedAmounts(amounts: 'Mapping[CapitalPlacement, Decimal]') -> None", + "FixedDollarSizing": "class alphalab.allocation.sizing.FixedDollarSizing()", + "FixedQuantitySizing": "class alphalab.allocation.sizing.FixedQuantitySizing()", + "IntentAllocator": "class alphalab.allocation.allocator.IntentAllocator()", + "LimitCheck": "class alphalab.allocation.capital.LimitCheck(limit: 'CapitalLimit', share: 'Decimal', satisfied: 'bool') -> None", + "NettingCompleted": "class alphalab.allocation.events.NettingCompleted(event_id: str, timestamp: float, asset_id: str, net_quantity: decimal.Decimal, side: str) -> None", + "NettingEngine": "class alphalab.allocation.netting.NettingEngine()", + "OrderRequest": "class alphalab.core.order_request.OrderRequest(order_id: str, strategy_id: str, asset_id: str, side: alphalab.core.enums.Side, quantity: decimal.Decimal, price: decimal.Decimal, timestamp: float = 0.0, contributions: tuple[alphalab.core.contribution.StrategyContribution, ...] = , terms: alphalab.common.order_terms.OrderTerms = OrderTerms(order_type=, limit_price=None, stop_price=None, time_in_force=, expire_at=None)) -> None", + "OversubscriptionRule": "enum alphalab.allocation.capital.OversubscriptionRule[REFUSE, PRO_RATA]", + "PlacementAllocation": "class alphalab.allocation.capital.PlacementAllocation(placement: 'CapitalPlacement', currency: 'str', requested: 'Decimal', allocated: 'Decimal', base_amount: 'Decimal', conversion: 'CapitalConversion | None') -> None", + "PlacementWeights": "class alphalab.allocation.capital.PlacementWeights(weights: 'Mapping[CapitalPlacement, Decimal]', source: 'str') -> None", + "RateRecord": "class alphalab.common.currency.RateRecord(*args, **kwargs)", + "SizingModel": "class alphalab.allocation.sizing.SizingModel(*args, **kwargs)", + "SizingRefusedError": "class alphalab.allocation.exceptions.SizingRefusedError", + "StrategyContribution": "class alphalab.core.contribution.StrategyContribution(strategy_id: str, quantity: decimal.Decimal) -> None", + "TargetWeightSizing": "class alphalab.allocation.sizing.TargetWeightSizing()", + "UnknownReservationError": "class alphalab.allocation.exceptions.UnknownReservationError", + "VolatilityTargetSizing": "class alphalab.allocation.sizing.VolatilityTargetSizing(target_vol: decimal.Decimal, asset_vols: collections.abc.Mapping[str, decimal.Decimal]) -> None", + "allocate_capital": "function alphalab.allocation.capital.allocate_capital(plan: 'CapitalAllocationPlan', converter: 'CurrencyConverter') -> 'CapitalAllocationResult'", + "allocation_history": "function alphalab.allocation.views.allocation_history(state: alphalab.allocation.state.AllocationState) -> collections.abc.Sequence[alphalab.core.order_request.OrderRequest]", + "capital_budget": "function alphalab.allocation.capital.capital_budget(result: 'CapitalAllocationResult', *, account_id: 'str', maximum_exposure: 'Decimal', cash_buffer: 'Decimal', enforce_strategy_budgets: 'bool' = False) -> 'CapitalBudget'", + "contributions_from": "function alphalab.core.contribution.contributions_from(sized: collections.abc.Iterable[tuple[str, decimal.Decimal]]) -> tuple[alphalab.core.contribution.StrategyContribution, ...]", + "current_budget": "function alphalab.allocation.views.current_budget(state: alphalab.allocation.state.AllocationState) -> alphalab.allocation.budget.CapitalBudget", + "open_reservations": "function alphalab.allocation.views.open_reservations(state: alphalab.allocation.state.AllocationState) -> collections.abc.Mapping[str, decimal.Decimal]", + "recent_orders_for_asset": "function alphalab.allocation.views.recent_orders_for_asset(state: alphalab.allocation.state.AllocationState, asset_id: str) -> collections.abc.Sequence[alphalab.core.order_request.OrderRequest]", + "reserved_capital": "function alphalab.allocation.capital.reserved_capital(state: 'AllocationState', *, currency: 'str') -> 'Decimal'", + "reserved_for_order": "function alphalab.allocation.views.reserved_for_order(state: alphalab.allocation.state.AllocationState, order_id: str) -> decimal.Decimal", + "total_notional_allocated": "function alphalab.allocation.views.total_notional_allocated(state: alphalab.allocation.state.AllocationState) -> decimal.Decimal", + "validate_long_only": "function alphalab.allocation.validation.validate_long_only(asset_id: str, delta: decimal.Decimal, committed: decimal.Decimal) -> None", + "validate_net_quantity": "function alphalab.allocation.validation.validate_net_quantity(quantity: decimal.Decimal, enforce_long_only: bool = False) -> None" + }, + "alphalab.allocation.snapshot": { + "ALLOCATION_SCHEMA_HISTORY": "value alphalab.persistence.upgrade.SchemaHistory", + "ALLOCATION_SNAPSHOT_SCHEMA": "value builtins.int", + "AllocationEventRecord": "class alphalab.allocation.snapshot.AllocationEventRecord(event_type: 'str', event: 'AllocationEvent') -> None", + "AllocationSnapshot": "class alphalab.allocation.snapshot.AllocationSnapshot(budget: 'CapitalBudget', history: 'tuple[OrderRequest, ...]', events: 'tuple[AllocationEventRecord, ...]', notional_allocated: 'Decimal', reservations: 'Mapping[str, Decimal]', contributions: 'Mapping[str, tuple[StrategyContribution, ...]]', strategy_positions: 'Mapping[str, Mapping[str, Decimal]] | None' = None, strategy_capital: 'Mapping[str, StrategyCapital]' = , schema_version: 'int' = 3) -> None", + "capture": "function alphalab.allocation.snapshot.capture(state: 'AllocationState') -> 'AllocationSnapshot'", + "from_primitives": "function alphalab.allocation.snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'AllocationSnapshot'", + "restore": "function alphalab.allocation.snapshot.restore(snapshot: 'AllocationSnapshot') -> 'AllocationState'" + }, + "alphalab.alt_data": { + "AdjustedFundamental": "class alphalab.alt_data.fundamentals.AdjustedFundamental(observation: 'FundamentalObservation', as_of: 'float', value: 'Decimal', unit: 'str', factor: 'Decimal', applied: 'tuple[ShareCountChange, ...]') -> None", + "AggregateStep": "class alphalab.alt_data.fundamentals.AggregateStep(known_at: 'float', value: 'Decimal | None', unit: 'str | None', observed_at: 'float | None', record_ids: 'tuple[str, ...]', reason: 'str') -> None", + "AggregatedSentiment": "class alphalab.alt_data.sentiment.AggregatedSentiment(asset_id: str, window_start: float, window_end: float, release_date: float, mean_sentiment: decimal.Decimal, volume: int, source: alphalab.alt_data.provenance.DataProvenance) -> None", + "Aggregation": "enum alphalab.alt_data.fundamentals.Aggregation[LATEST, LATEST_ANNUAL, TRAILING_TWELVE_MONTHS]", + "AltDataError": "class alphalab.alt_data.exceptions.AltDataError", + "AltDataFeatureValue": "class alphalab.alt_data.feature_bridge.AltDataFeatureValue(feature_id: str, version: int, asset_id: str | None, value: float, timestamp: float) -> None", + "AltDataInputError": "class alphalab.alt_data.exceptions.AltDataInputError", + "BOOK_EQUITY": "value builtins.str", + "CASH": "value builtins.str", + "CURRENT_ASSETS": "value builtins.str", + "CURRENT_LIABILITIES": "value builtins.str", + "ConvertedFundamental": "class alphalab.alt_data.fundamentals.ConvertedFundamental(observation: 'FundamentalObservation', as_of: 'float', value: 'Decimal', unit: 'str', rate: 'Decimal | None', rate_pair: 'tuple[str, str] | None', rate_as_of: 'float | None', rate_source: 'str | None') -> None", + "CreditCardSpendingObservation": "class alphalab.alt_data.credit_card.CreditCardSpendingObservation(asset_id: str, reference_period: float, release_date: float, spending_growth_yoy: decimal.Decimal, source: alphalab.alt_data.provenance.DataProvenance, transaction_count_growth_yoy: decimal.Decimal | None = None) -> None", + "DIVIDENDS_PER_SHARE": "value builtins.str", + "DataProvenance": "class alphalab.alt_data.provenance.DataProvenance(vendor: str, coverage_description: str, confidence: decimal.Decimal) -> None", + "DeliverySchedule": "class alphalab.alt_data.streaming.DeliverySchedule(set_version: 'str', visibility: 'VisibilityRule', deliveries: 'tuple[ObservationDelivery[T], ...]', unverified: 'int') -> None", + "EARNINGS": "value builtins.str", + "EARNINGS_PER_SHARE": "value builtins.str", + "EBITDA": "value builtins.str", + "ESGScore": "class alphalab.alt_data.esg.ESGScore(asset_id: str, reference_period: float, release_date: float, environmental_score: decimal.Decimal, social_score: decimal.Decimal, governance_score: decimal.Decimal, source: alphalab.alt_data.provenance.DataProvenance) -> None", + "ExternalObservation": "class alphalab.alt_data.observation.ExternalObservation(category: 'str', subject: 'str', metric: 'str', value: 'Decimal', unit: 'str', stamp: 'PointInTimeStamp', source: 'ObservationSource', period: 'ReferencePeriod | None', revision: 'int') -> None", + "FREE_CASH_FLOW": "value builtins.str", + "FUNDAMENTAL_KEY_SCHEME": "value builtins.str", + "FinancialRatios": "class alphalab.alt_data.fundamentals.FinancialRatios(subject: 'str', as_of: 'float', gross_margin: 'Decimal | None', operating_margin: 'Decimal | None', net_margin: 'Decimal | None', return_on_equity: 'Decimal | None', return_on_assets: 'Decimal | None', debt_to_equity: 'Decimal | None', current_ratio: 'Decimal | None', undefined: 'Mapping[str, str]', record_ids: 'tuple[str, ...]') -> None", + "FinancialStatement": "class alphalab.alt_data.fundamentals.FinancialStatement(subject: 'str', statement: 'StatementType', fiscal_period: 'FiscalPeriod', as_of: 'float', policy: 'VintagePolicy', items: 'Mapping[str, FundamentalObservation]') -> None", + "FiscalPeriod": "class alphalab.alt_data.fundamentals.FiscalPeriod(fiscal_year: 'int', fiscal_quarter: 'int | None', start: 'float', end: 'float') -> None", + "FundamentalInput": "class alphalab.alt_data.fundamentals.FundamentalInput(name: 'str', statement: 'StatementType', line_item: 'str', aggregation: 'Aggregation') -> None", + "FundamentalInputs": "class alphalab.alt_data.fundamentals.FundamentalInputs(subject: 'str', as_of: 'float', visibility: 'VisibilityRule', policy: 'VintagePolicy', values: 'Mapping[str, Decimal]', units: 'Mapping[str, str]', record_ids: 'Mapping[str, tuple[str, ...]]', observed_at: 'Mapping[str, float]', missing: 'Mapping[str, str]') -> None", + "FundamentalObservation": "class alphalab.alt_data.fundamentals.FundamentalObservation(subject: 'str', statement: 'StatementType', line_item: 'str', fiscal_period: 'FiscalPeriod', value: 'Decimal', unit: 'str', published_at: 'float', stamp: 'PointInTimeStamp', source: 'ObservationSource', revision: 'int') -> None", + "GROSS_PROFIT": "value builtins.str", + "GrowthValue": "class alphalab.alt_data.fundamentals.GrowthValue(subject: 'str', line_item: 'str', as_of: 'float', current: 'FundamentalObservation | None', prior: 'FundamentalObservation | None', growth: 'Decimal | None', reason: 'str') -> None", + "INFORMATION_EVENT_KEY_SCHEME": "value builtins.str", + "InformationEvent": "class alphalab.alt_data.information.InformationEvent(event_type: 'str', subject: 'str', stamp: 'PointInTimeStamp', source: 'ObservationSource', measurements: 'Mapping[str, Decimal]', attributes: 'Mapping[str, str]', revision: 'int') -> None", + "NewsSentiment": "class alphalab.alt_data.news.NewsSentiment(asset_id: str, headline: str, published_at: float, release_date: float, sentiment_score: decimal.Decimal, source: alphalab.alt_data.provenance.DataProvenance) -> None", + "OBSERVATION_KEY_SCHEME": "value builtins.str", + "OBSERVATION_SET_KEY_SCHEME": "value builtins.str", + "OPERATING_INCOME": "value builtins.str", + "ObservationDelivery": "class alphalab.alt_data.streaming.ObservationDelivery(known_at: 'float', delivery_id: 'str', record: 'T') -> None", + "ObservationSet": "class alphalab.alt_data.observation_set.ObservationSet(name: 'str', kind: 'str', source: 'ObservationSource', records: 'tuple[T, ...]', parent_version: 'str | None', transformations: 'tuple[SetTransformation, ...]', version: 'str') -> None", + "ObservationSource": "class alphalab.alt_data.source.ObservationSource(source_id: 'str', version: 'str | None', content_hash: 'str | None', retrieved_at: 'float | None', quality: 'DataProvenance | None' = None) -> None", + "ObservationStream": "class alphalab.alt_data.streaming.ObservationStream(name: 'str', source: 'ObservationSource', kind: 'str | None' = None, records: 'AppendOnlyLog[T]' = , _ids: 'PersistentMap[str, None]' = , _vintages: 'PersistentMap[tuple[str, ...], PersistentMap[int, float | None]]' = ) -> None", + "ObservationView": "class alphalab.alt_data.observation_set.ObservationView(set_version: 'str', visibility: 'VisibilityRule', index: 'PointInTimeIndex[T]', series: 'Mapping[tuple[str, ...], PointInTimeIndex[T]]', figures: 'Mapping[tuple[str, ...], tuple[T, ...]]') -> None", + "PointInTimeError": "class alphalab.alt_data.exceptions.PointInTimeError", + "PointInTimeSelection": "class alphalab.alt_data.observation_set.PointInTimeSelection(set_version: 'str', as_of: 'float', visibility: 'VisibilityRule', records: 'tuple[T, ...]', pending: 'int', unverified: 'int') -> None", + "QuotedRate": "class alphalab.alt_data.fundamentals.QuotedRate(*args, **kwargs)", + "REVENUE": "value builtins.str", + "RateTable": "class alphalab.alt_data.fundamentals.RateTable(*args, **kwargs)", + "ReferencePeriod": "class alphalab.alt_data.observation.ReferencePeriod(start: 'float', end: 'float', label: 'str') -> None", + "Restatement": "class alphalab.alt_data.fundamentals.Restatement(original: 'FundamentalObservation', latest: 'FundamentalObservation', change: 'Decimal', revisions: 'int') -> None", + "SHARES_OUTSTANDING": "value builtins.str", + "SatelliteMetricType": "enum alphalab.alt_data.satellite.SatelliteMetricType[PARKING_LOT_TRAFFIC, STORAGE_TANK_FILL_LEVEL, CROP_HEALTH_INDEX, CONSTRUCTION_ACTIVITY]", + "SatelliteObservation": "class alphalab.alt_data.satellite.SatelliteObservation(asset_id: str, metric_type: alphalab.alt_data.satellite.SatelliteMetricType, capture_date: float, release_date: float, value: decimal.Decimal, source: alphalab.alt_data.provenance.DataProvenance) -> None", + "SessionCalendar": "class alphalab.alt_data.sessions.SessionCalendar(*args, **kwargs)", + "SessionPlacement": "class alphalab.alt_data.sessions.SessionPlacement(calendar_id: 'str', instant: 'float', timing: 'SessionTiming', tradable_at: 'float') -> None", + "SessionTiming": "enum alphalab.alt_data.sessions.SessionTiming[IN_SESSION, BEFORE_OPEN, BETWEEN_SESSIONS, AFTER_CLOSE, NON_TRADING_DAY]", + "SetRecord": "class alphalab.alt_data.observation_set.SetRecord(*args, **kwargs)", + "SetTransformation": "class alphalab.alt_data.observation_set.SetTransformation(operation: 'str', detail: 'str', records_before: 'int', records_after: 'int') -> None", + "ShareCountChange": "class alphalab.alt_data.fundamentals.ShareCountChange(subject: 'str', effective_at: 'float', ratio: 'Decimal', source: 'str', known_at: 'float | None' = None) -> None", + "ShippingMetricType": "enum alphalab.alt_data.shipping.ShippingMetricType[PORT_CONGESTION_INDEX, VESSEL_COUNT, CARGO_VOLUME]", + "ShippingObservation": "class alphalab.alt_data.shipping.ShippingObservation(identifier: str, metric_type: alphalab.alt_data.shipping.ShippingMetricType, reference_period: float, release_date: float, value: decimal.Decimal, source: alphalab.alt_data.provenance.DataProvenance) -> None", + "StatementType": "enum alphalab.alt_data.fundamentals.StatementType[INCOME_STATEMENT, BALANCE_SHEET, CASH_FLOW_STATEMENT]", + "TOTAL_ASSETS": "value builtins.str", + "TOTAL_DEBT": "value builtins.str", + "TrailingValue": "class alphalab.alt_data.fundamentals.TrailingValue(subject: 'str', line_item: 'str', as_of: 'float', value: 'Decimal | None', unit: 'str | None', periods: 'tuple[str, ...]', record_ids: 'tuple[str, ...]', reason: 'str') -> None", + "ValuationMetrics": "class alphalab.alt_data.fundamentals.ValuationMetrics(subject: 'str', as_of: 'float', currency: 'str', price: 'Decimal', price_observed_at: 'float', market_capitalization: 'Decimal | None', enterprise_value: 'Decimal | None', earnings_yield: 'Decimal | None', price_to_earnings: 'Decimal | None', book_to_market: 'Decimal | None', price_to_book: 'Decimal | None', sales_to_price: 'Decimal | None', enterprise_value_to_ebitda: 'Decimal | None', enterprise_value_to_sales: 'Decimal | None', free_cash_flow_yield: 'Decimal | None', dividend_yield: 'Decimal | None', undefined: 'Mapping[str, str]', record_ids: 'tuple[str, ...]') -> None", + "VintagePolicy": "enum alphalab.alt_data.observation_set.VintagePolicy[AS_KNOWN, ORIGINAL]", + "adjusted_for_share_changes": "function alphalab.alt_data.fundamentals.adjusted_for_share_changes(observation: 'FundamentalObservation', changes: 'Iterable[ShareCountChange]', as_of: 'float') -> 'AdjustedFundamental'", + "aggregate_from_news": "function alphalab.alt_data.sentiment.aggregate_from_news(news_items: tuple[alphalab.alt_data.news.NewsSentiment, ...], asset_id: str, window_start: float, window_end: float, source: alphalab.alt_data.provenance.DataProvenance) -> alphalab.alt_data.sentiment.AggregatedSentiment", + "aggregate_timeline": "function alphalab.alt_data.fundamentals.aggregate_timeline(view: 'ObservationView[FundamentalObservation]', subject: 'str', spec: 'FundamentalInput', policy: 'VintagePolicy') -> 'tuple[AggregateStep, ...]'", + "build_observation_set": "function alphalab.alt_data.observation_set.build_observation_set(name: 'str', records: 'Iterable[T]', source: 'ObservationSource') -> 'ObservationSet[T]'", + "canonical_event_key": "function alphalab.alt_data.information.canonical_event_key(event: 'InformationEvent') -> 'str'", + "canonical_fundamental_key": "function alphalab.alt_data.fundamentals.canonical_fundamental_key(observation: 'FundamentalObservation') -> 'str'", + "canonical_observation_key": "function alphalab.alt_data.observation.canonical_observation_key(observation: 'ExternalObservation') -> 'str'", + "canonical_set_key": "function alphalab.alt_data.observation_set.canonical_set_key(name: 'str', kind: 'str', source: 'ObservationSource', parent_version: 'str | None', transformations: 'Iterable[SetTransformation]', record_ids: 'Iterable[str]') -> 'str'", + "composite_score": "function alphalab.alt_data.esg.composite_score(esg: alphalab.alt_data.esg.ESGScore, weights: tuple[decimal.Decimal, decimal.Decimal, decimal.Decimal] = (Decimal('0.3333333333333333333333333333'), Decimal('0.3333333333333333333333333333'), Decimal('0.3333333333333333333333333333'))) -> decimal.Decimal", + "converted_fundamental": "function alphalab.alt_data.fundamentals.converted_fundamental(observation: 'FundamentalObservation', currency: 'str', rates: 'RateTable', as_of: 'float') -> 'ConvertedFundamental'", + "currency_of_per_share_unit": "function alphalab.alt_data.fundamentals.currency_of_per_share_unit(unit: 'str') -> 'str'", + "delivery_schedule": "function alphalab.alt_data.streaming.delivery_schedule(obs_set: 'ObservationSet[T]', visibility: 'VisibilityRule') -> 'DeliverySchedule[T]'", + "derive_set_version": "function alphalab.alt_data.observation_set.derive_set_version(name: 'str', kind: 'str', source: 'ObservationSource', parent_version: 'str | None', transformations: 'Iterable[SetTransformation]', record_ids: 'Iterable[str]') -> 'str'", + "financial_ratios": "function alphalab.alt_data.fundamentals.financial_ratios(inputs: 'FundamentalInputs') -> 'FinancialRatios'", + "from_aggregated_sentiment": "function alphalab.alt_data.feature_bridge.from_aggregated_sentiment(obs: alphalab.alt_data.sentiment.AggregatedSentiment, feature_id: str, version: int) -> alphalab.alt_data.feature_bridge.AltDataFeatureValue", + "from_credit_card_observation": "function alphalab.alt_data.feature_bridge.from_credit_card_observation(obs: alphalab.alt_data.credit_card.CreditCardSpendingObservation, feature_id: str, version: int) -> alphalab.alt_data.feature_bridge.AltDataFeatureValue", + "from_esg_score": "function alphalab.alt_data.feature_bridge.from_esg_score(obs: alphalab.alt_data.esg.ESGScore, feature_id: str, version: int, weights: tuple[decimal.Decimal, decimal.Decimal, decimal.Decimal] | None = None) -> alphalab.alt_data.feature_bridge.AltDataFeatureValue", + "from_news_sentiment": "function alphalab.alt_data.feature_bridge.from_news_sentiment(obs: alphalab.alt_data.news.NewsSentiment, feature_id: str, version: int) -> alphalab.alt_data.feature_bridge.AltDataFeatureValue", + "from_satellite_observation": "function alphalab.alt_data.feature_bridge.from_satellite_observation(obs: alphalab.alt_data.satellite.SatelliteObservation, feature_id: str, version: int) -> alphalab.alt_data.feature_bridge.AltDataFeatureValue", + "from_shipping_observation": "function alphalab.alt_data.feature_bridge.from_shipping_observation(obs: alphalab.alt_data.shipping.ShippingObservation, feature_id: str, version: int) -> alphalab.alt_data.feature_bridge.AltDataFeatureValue", + "fundamental_as_of": "function alphalab.alt_data.fundamentals.fundamental_as_of(view: 'ObservationView[FundamentalObservation]', subject: 'str', statement: 'StatementType', line_item: 'str', fiscal_period_label: 'str', as_of: 'float', policy: 'VintagePolicy') -> 'FundamentalObservation | None'", + "fundamental_inputs_as_of": "function alphalab.alt_data.fundamentals.fundamental_inputs_as_of(view: 'ObservationView[FundamentalObservation]', subject: 'str', as_of: 'float', policy: 'VintagePolicy', inputs: 'Sequence[FundamentalInput]') -> 'FundamentalInputs'", + "known_by": "function alphalab.alt_data.observation_set.known_by(obs_set: 'ObservationSet[T]', as_of: 'float', visibility: 'VisibilityRule') -> 'ObservationSet[T]'", + "latest_fundamental": "function alphalab.alt_data.fundamentals.latest_fundamental(view: 'ObservationView[FundamentalObservation]', subject: 'str', statement: 'StatementType', line_item: 'str', as_of: 'float', policy: 'VintagePolicy', *, annual: 'bool | None') -> 'FundamentalObservation | None'", + "latest_vintages": "function alphalab.alt_data.observation_set.latest_vintages(records: 'Iterable[T]') -> 'tuple[T, ...]'", + "observation_from_release": "function alphalab.alt_data.observation.observation_from_release(record: 'PointInTimeRecord', *, category: 'str', subject: 'str', metric: 'str', value: 'Decimal', unit: 'str', source: 'ObservationSource', revision: 'int') -> 'ExternalObservation'", + "original_vintages": "function alphalab.alt_data.observation_set.original_vintages(records: 'Iterable[T]') -> 'tuple[T, ...]'", + "originals_only": "function alphalab.alt_data.observation_set.originals_only(obs_set: 'ObservationSet[T]') -> 'ObservationSet[T]'", + "place_in_session": "function alphalab.alt_data.sessions.place_in_session(instant: 'float', calendar: 'SessionCalendar') -> 'SessionPlacement'", + "place_record": "function alphalab.alt_data.sessions.place_record(record: 'StampedRecord', visibility: 'VisibilityRule', calendar: 'SessionCalendar') -> 'SessionPlacement'", + "restatements": "function alphalab.alt_data.fundamentals.restatements(obs_set: 'ObservationSet[FundamentalObservation]') -> 'tuple[Restatement, ...]'", + "restrict_measured": "function alphalab.alt_data.information.restrict_measured(event_set: 'ObservationSet[InformationEvent]', measurement: 'str') -> 'ObservationSet[InformationEvent]'", + "restrict_observed": "function alphalab.alt_data.observation_set.restrict_observed(obs_set: 'ObservationSet[T]', start: 'float', end: 'float') -> 'ObservationSet[T]'", + "restrict_subjects": "function alphalab.alt_data.observation_set.restrict_subjects(obs_set: 'ObservationSet[T]', subjects: 'Iterable[str]') -> 'ObservationSet[T]'", + "statement_as_of": "function alphalab.alt_data.fundamentals.statement_as_of(view: 'ObservationView[FundamentalObservation]', subject: 'str', statement: 'StatementType', fiscal_period_label: 'str', as_of: 'float', policy: 'VintagePolicy') -> 'FinancialStatement | None'", + "surprise": "function alphalab.alt_data.information.surprise(event: 'InformationEvent', actual: 'str', expected: 'str') -> 'Decimal'", + "trailing_twelve_months": "function alphalab.alt_data.fundamentals.trailing_twelve_months(view: 'ObservationView[FundamentalObservation]', subject: 'str', statement: 'StatementType', line_item: 'str', as_of: 'float', policy: 'VintagePolicy') -> 'TrailingValue'", + "valuation_metrics": "function alphalab.alt_data.fundamentals.valuation_metrics(inputs: 'FundamentalInputs', price: 'Decimal', price_unit: 'str', price_observed_at: 'float') -> 'ValuationMetrics'", + "verify_observation_set": "function alphalab.alt_data.observation_set.verify_observation_set(obs_set: 'ObservationSet[T]') -> 'bool'", + "year_over_year_growth": "function alphalab.alt_data.fundamentals.year_over_year_growth(view: 'ObservationView[FundamentalObservation]', subject: 'str', statement: 'StatementType', line_item: 'str', as_of: 'float', policy: 'VintagePolicy', *, annual: 'bool') -> 'GrowthValue'" + }, + "alphalab.analytics": { + "AnalyticsEngine": "class alphalab.analytics.engine.AnalyticsEngine()", + "AnalyticsError": "class alphalab.analytics.exceptions.AnalyticsError", + "AnalyticsEvent": "class alphalab.analytics.events.AnalyticsEvent(event_id: str, timestamp: float) -> None", + "AnalyticsState": "class alphalab.analytics.state.AnalyticsState(reports: tuple[alphalab.analytics.report.PerformanceReport, ...] = , events: tuple[alphalab.analytics.events.AnalyticsEvent, ...] = ) -> None", + "AnalyticsValidationError": "class alphalab.analytics.exceptions.AnalyticsValidationError", + "AttributionDimension": "enum alphalab.analytics.attribution.AttributionDimension[STRATEGY, ASSET, SECTOR, COUNTRY, CURRENCY, VENUE, BROKER, FACTOR, EXECUTION]", + "AttributionMetrics": "class alphalab.analytics.attribution.AttributionMetrics(pnl_by_strategy: collections.abc.Mapping[str, decimal.Decimal], pnl_by_asset: collections.abc.Mapping[str, decimal.Decimal], pnl_by_sector: collections.abc.Mapping[str, decimal.Decimal]) -> None", + "AttributionReport": "class alphalab.analytics.attribution.AttributionReport(dimensions: collections.abc.Mapping[alphalab.analytics.attribution.AttributionDimension, alphalab.analytics.attribution.DimensionAttribution], realized_pnl: decimal.Decimal, currencies: tuple[str, ...]) -> None", + "Availability": "enum alphalab.analytics.attribution.Availability[AVAILABLE, PARTIAL, NO_METADATA]", + "BenchmarkBasis": "class alphalab.analytics.benchmark.BenchmarkBasis(periods_per_year: 'float', risk_free_rate: 'float') -> None", + "BenchmarkStatistics": "class alphalab.analytics.benchmark.BenchmarkStatistics(basis: 'BenchmarkBasis', observations: 'int', portfolio_only_instants: 'int', benchmark_only_instants: 'int', active_return: 'float | None', tracking_error: 'float | None', information_ratio: 'float | None', beta: 'float | None', alpha: 'float | None', correlation: 'float | None', r_squared: 'float | None', up_capture: 'float | None', down_capture: 'float | None', relative_return: 'float') -> None", + "BucketRisk": "class alphalab.analytics.risk_budget.BucketRisk(bucket: 'str', net_exposure: 'Decimal', gross_exposure: 'Decimal', capital_share: 'float', contribution: 'float', share: 'float', lines: 'int', native_exposure: 'Decimal | None') -> None", + "BudgetBasis": "enum alphalab.analytics.risk_budget.BudgetBasis[ABSOLUTE, RELATIVE]", + "BudgetCheck": "class alphalab.analytics.risk_budget.BudgetCheck(limit: 'BudgetLimit', present: 'bool', used: 'float', remaining: 'float | None', above_minimum: 'float | None', deviation: 'float | None', status: 'BudgetStatus') -> None", + "BudgetLimit": "class alphalab.analytics.risk_budget.BudgetLimit(dimension: 'RiskDimension', bucket: 'str', basis: 'BudgetBasis', maximum: 'float | None', minimum: 'float | None', target: 'float | None') -> None", + "BudgetStatus": "enum alphalab.analytics.risk_budget.BudgetStatus[WITHIN, AT_LIMIT, BREACHED, BELOW_MINIMUM]", + "CLASSIFICATION_SCHEME": "value builtins.str", + "COVARIANCE_SCHEME": "value builtins.str", + "CapitalConcentration": "class alphalab.analytics.cross_strategy.CapitalConcentration(dimension: 'str', currency: 'str', amounts: 'Mapping[str, Decimal]', total: 'Decimal', shares: 'Mapping[str, float]', herfindahl: 'float', effective_count: 'float', largest: 'str', largest_share: 'float') -> None", + "Classification": "class alphalab.analytics.risk_model.Classification(dimension: 'str', labels: 'Mapping[str, str]', source: 'str', as_of: 'float | None') -> None", + "CommonDimension": "enum alphalab.analytics.cross_strategy.CommonDimension[INSTRUMENT, CURRENCY, CLASSIFICATION]", + "CommonExposure": "class alphalab.analytics.cross_strategy.CommonExposure(bucket: 'str', by_strategy: 'Mapping[str, Decimal]', net_exposure: 'Decimal', gross_exposure: 'Decimal', shared: 'bool', opposing: 'bool') -> None", + "CommonExposureReport": "class alphalab.analytics.cross_strategy.CommonExposureReport(dimension: 'CommonDimension', label: 'str', source: 'str', buckets: 'tuple[CommonExposure, ...]') -> None", + "ConcentrationMetrics": "class alphalab.analytics.decomposition.ConcentrationMetrics(herfindahl: 'float', effective_positions: 'float', largest_weight: 'float', largest_asset_id: 'str') -> None", + "CorrelationMatrix": "class alphalab.analytics.risk_model.CorrelationMatrix(assets: 'tuple[str, ...]', values: 'tuple[tuple[float, ...], ...]', currency: 'str', period: 'str', observations: 'int | None', covariance_id: 'str') -> None", + "CovarianceMatrix": "class alphalab.analytics.risk_model.CovarianceMatrix(assets: 'tuple[str, ...]', values: 'tuple[tuple[float, ...], ...]', currency: 'str', period: 'str', source: 'str', observations: 'int | None', parent_id: 'str | None' = None, derivation: 'str | None' = None, factors: 'FactorStructure | None' = None) -> None", + "Definiteness": "class alphalab.analytics.risk_model.Definiteness(kind: 'DefinitenessKind', rank: 'int', smallest_pivot: 'float', largest_pivot: 'float', pivot_floor: 'float', zero_variance_assets: 'tuple[str, ...]') -> None", + "DefinitenessKind": "enum alphalab.analytics.risk_model.DefinitenessKind[POSITIVE_DEFINITE, SINGULAR, INDEFINITE]", + "DimensionAttribution": "class alphalab.analytics.attribution.DimensionAttribution(dimension: alphalab.analytics.attribution.AttributionDimension, buckets: collections.abc.Mapping[str, decimal.Decimal], availability: alphalab.analytics.attribution.Availability, covered: int, total: int, uncovered: tuple[str, ...], reconciles: bool) -> None", + "DimensionRisk": "class alphalab.analytics.risk_budget.DimensionRisk(dimension: 'RiskDimension', buckets: 'tuple[BucketRisk, ...]', total_contribution: 'float', residual: 'float', source: 'str', classification_id: 'str | None') -> None", + "DrawdownMetrics": "class alphalab.analytics.drawdown.DrawdownMetrics(drawdowns: tuple[float, ...], max_drawdown: float, ulcer_index: float) -> None", + "EXCHANGE_RATE_FACTOR_PREFIX": "value builtins.str", + "ExposureLine": "class alphalab.analytics.risk_budget.ExposureLine(*args, **kwargs)", + "ExposureMetrics": "class alphalab.analytics.exposure.ExposureMetrics(gross: decimal.Decimal, net: decimal.Decimal, long: decimal.Decimal, short: decimal.Decimal, cash_pct: float, leverage: float) -> None", + "ExposureSimilarity": "class alphalab.analytics.cross_strategy.ExposureSimilarity(first: 'str', second: 'str', shared_instruments: 'tuple[str, ...]', jaccard: 'float', same_direction_overlap: 'Decimal', opposing_overlap: 'Decimal', first_overlap_share: 'float', second_overlap_share: 'float', cosine_similarity: 'float | None', exposure_correlation: 'float | None') -> None", + "FACTOR_LOADINGS_SCHEME": "value builtins.str", + "FACTOR_STRUCTURE_SCHEME": "value builtins.str", + "FactorCrowding": "class alphalab.analytics.cross_strategy.FactorCrowding(factor: 'str', by_strategy: 'Mapping[str, float]', aggregate_exposure: 'float', gross_exposure: 'float', alignment: 'float | None', concentration: 'float | None', aligned_strategies: 'tuple[str, ...]') -> None", + "FactorCrowdingReport": "class alphalab.analytics.cross_strategy.FactorCrowdingReport(loadings_id: 'str', capital: 'Decimal', factors: 'tuple[FactorCrowding, ...]', pairs: 'tuple[FactorExposureOverlap, ...]') -> None", + "FactorExposureOverlap": "class alphalab.analytics.cross_strategy.FactorExposureOverlap(first: 'str', second: 'str', cosine_similarity: 'float | None') -> None", + "FactorLoadings": "class alphalab.analytics.risk_model.FactorLoadings(factors: 'tuple[str, ...]', assets: 'tuple[str, ...]', values: 'tuple[tuple[float, ...], ...]', source: 'str', lineage: 'Mapping[str, str]', as_of: 'float | None') -> None", + "FactorRisk": "class alphalab.analytics.risk_model.FactorRisk(covariance_id: 'str', currency: 'str', period: 'str', volatility: 'float', exposures: 'Mapping[str, float]', factors: 'Mapping[str, float]', specific: 'Mapping[str, float]', factor_total: 'float', specific_total: 'float', residual: 'float') -> None", + "FactorStructure": "class alphalab.analytics.risk_model.FactorStructure(loadings: 'FactorLoadings', factor_covariance: 'CovarianceMatrix', specific: 'tuple[float, ...]') -> None", + "LeverageMetrics": "class alphalab.analytics.decomposition.LeverageMetrics(gross_exposure: 'Decimal', net_exposure: 'Decimal', gross_leverage: 'Decimal', net_leverage: 'Decimal', long_exposure: 'Decimal', short_exposure: 'Decimal') -> None", + "LineRisk": "class alphalab.analytics.risk_budget.LineRisk(strategy_id: 'str', asset_id: 'str', currency: 'str', native_value: 'Decimal', value: 'Decimal', weight: 'float', contribution: 'float') -> None", + "LiquidityRisk": "class alphalab.analytics.decomposition.LiquidityRisk(days_to_liquidate: 'Mapping[str, float]', worst_asset_id: 'str', worst_days: 'float', participation_limit: 'Decimal') -> None", + "PerformanceReport": "class alphalab.analytics.report.PerformanceReport(report_id: str, timestamp: float, returns: alphalab.analytics.report.ReturnSummary, risk: alphalab.analytics.report.RiskSummary, drawdowns: alphalab.analytics.drawdown.DrawdownMetrics, exposure: alphalab.analytics.exposure.ExposureMetrics, trades: alphalab.analytics.summary.TradeMetrics, attribution: alphalab.analytics.attribution.AttributionMetrics, ending_capital: decimal.Decimal) -> None", + "Periodicity": "enum alphalab.analytics.report.Periodicity[DECLARED, OBSERVED, UNDEFINED, ASSUMED]", + "PortfolioSnapshot": "class alphalab.analytics.engine.PortfolioSnapshot(timestamp: float, total_equity: decimal.Decimal, cash: decimal.Decimal, long_exposure: decimal.Decimal, short_exposure: decimal.Decimal) -> None", + "PositionRisk": "class alphalab.analytics.decomposition.PositionRisk(asset_id: 'str', market_value: 'Decimal', currency: 'str', returns: 'tuple[float, ...]') -> None", + "RISK_BUDGET_REPORT_SCHEME": "value builtins.str", + "RISK_BUDGET_SCHEME": "value builtins.str", + "ReportGenerated": "class alphalab.analytics.events.ReportGenerated(event_id: str, timestamp: float, report_id: str, num_snapshots: int, num_trades: int) -> None", + "ReturnSummary": "class alphalab.analytics.report.ReturnSummary(total_return: float | None, cagr: float | None, arithmetic_return: float | None, geometric_return: float | None, period_returns: tuple[float, ...], periods_per_year: float | None = None, periodicity: alphalab.analytics.report.Periodicity = , years_elapsed: float | None = None) -> None", + "RiskBudget": "class alphalab.analytics.risk_budget.RiskBudget(name: 'str', limits: 'tuple[BudgetLimit, ...]', tolerance: 'float') -> None", + "RiskBudgetReport": "class alphalab.analytics.risk_budget.RiskBudgetReport(budget_id: 'str', reporting_currency: 'str', period: 'str', capital: 'Decimal', covariance_id: 'str', volatility: 'float', risk: 'RiskContributions', lines: 'tuple[LineRisk, ...]', dimensions: 'tuple[DimensionRisk, ...]', checks: 'tuple[BudgetCheck, ...]') -> None", + "RiskContributions": "class alphalab.analytics.risk_model.RiskContributions(weights: 'Mapping[str, float]', variance: 'float', volatility: 'float', marginal: 'Mapping[str, float]', total: 'Mapping[str, float]', relative: 'Mapping[str, float]', covariance_id: 'str') -> None", + "RiskDecomposition": "class alphalab.analytics.decomposition.RiskDecomposition(policy: 'VaRPolicy', volatility: 'float', value_at_risk: 'float', conditional_value_at_risk: 'float', tail_ratio: 'float', beta: 'float | None', max_drawdown: 'float', concentration: 'ConcentrationMetrics', leverage: 'LeverageMetrics', risk_contributions: 'Mapping[str, float]', factor_exposure: 'Mapping[str, float]', liquidity: 'LiquidityRisk | None') -> None", + "RiskDimension": "enum alphalab.analytics.risk_budget.RiskDimension[ASSET, STRATEGY, SECTOR, COUNTRY, CURRENCY]", + "RiskSummary": "class alphalab.analytics.report.RiskSummary(sharpe_ratio: float | None, sortino_ratio: float | None, calmar_ratio: float | None, value_at_risk_95: float | None, cvar_95: float | None, annualized_volatility: float | None, risk_free_rate: float | None = None) -> None", + "SharedCapitalPool": "class alphalab.analytics.cross_strategy.SharedCapitalPool(pool: 'str', by_strategy: 'Mapping[str, Decimal]', total: 'Decimal', shared: 'bool') -> None", + "StrategyCorrelation": "class alphalab.analytics.cross_strategy.StrategyCorrelation(covariance: 'CovarianceMatrix', correlation: 'CorrelationMatrix') -> None", + "StrategyReturns": "class alphalab.analytics.cross_strategy.StrategyReturns(strategy_id: 'str', returns: 'tuple[float, ...]', currency: 'str', period: 'str') -> None", + "TradeFacts": "class alphalab.analytics.attribution.TradeFacts(trade_id: str, currency: str | None = None, venue: str | None = None, broker: str | None = None, country: str | None = None, factor_pnl: collections.abc.Mapping[str, decimal.Decimal] | None = None, execution_costs: collections.abc.Mapping[str, decimal.Decimal] | None = None) -> None", + "TradeMetrics": "class alphalab.analytics.summary.TradeMetrics(win_rate: float | None, loss_rate: float | None, avg_win: decimal.Decimal | None, avg_loss: decimal.Decimal | None, profit_factor: float | None, expectancy: decimal.Decimal | None, avg_holding_period: float | None, turnover: float | None, fills: int | None = None, closed_trades: int | None = None) -> None", + "TradeRecord": "class alphalab.analytics.attribution.TradeRecord(trade_id: str, asset_id: str, sector_id: str | None, realized_pnl: decimal.Decimal, notional_value: decimal.Decimal, holding_period_seconds: float | None, contributions: tuple[alphalab.core.contribution.StrategyContribution, ...] = ) -> None", + "VaRMethod": "enum alphalab.analytics.decomposition.VaRMethod[HISTORICAL, GAUSSIAN, CORNISH_FISHER]", + "VaRPolicy": "class alphalab.analytics.decomposition.VaRPolicy(method: 'VaRMethod', confidence: 'float') -> None", + "all_reports": "function alphalab.analytics.views.all_reports(state: alphalab.analytics.state.AnalyticsState) -> tuple[alphalab.analytics.report.PerformanceReport, ...]", + "annualized_volatility": "function alphalab.analytics.returns.annualized_volatility(returns: tuple[float, ...], periods: float) -> float | None", + "arithmetic_return": "function alphalab.analytics.returns.arithmetic_return(returns: tuple[float, ...]) -> float | None", + "attribute": "function alphalab.analytics.attribution.attribute(trades: collections.abc.Sequence[alphalab.analytics.attribution.TradeRecord], facts: collections.abc.Mapping[str, alphalab.analytics.attribution.TradeFacts] | None = None, dimensions: collections.abc.Sequence[alphalab.analytics.attribution.AttributionDimension] | None = None) -> alphalab.analytics.attribution.AttributionReport", + "benchmark_statistics": "function alphalab.analytics.benchmark.benchmark_statistics(portfolio: 'Mapping[float, Decimal | float]', benchmark: 'Mapping[float, Decimal | float]', basis: 'BenchmarkBasis') -> 'BenchmarkStatistics'", + "cagr": "function alphalab.analytics.returns.cagr(start_value: decimal.Decimal, end_value: decimal.Decimal, years: float) -> float | None", + "calculate_attribution": "function alphalab.analytics.attribution.calculate_attribution(trades: collections.abc.Sequence[alphalab.analytics.attribution.TradeRecord]) -> alphalab.analytics.attribution.AttributionMetrics", + "calculate_drawdowns": "function alphalab.analytics.drawdown.calculate_drawdowns(equity_curve: tuple[float, ...]) -> alphalab.analytics.drawdown.DrawdownMetrics", + "calculate_exposure": "function alphalab.analytics.exposure.calculate_exposure(long_val: decimal.Decimal, short_val: decimal.Decimal, cash: decimal.Decimal) -> alphalab.analytics.exposure.ExposureMetrics", + "calculate_trade_metrics": "function alphalab.analytics.summary.calculate_trade_metrics(profits: tuple[decimal.Decimal, ...], holding_periods: tuple[float | None, ...], total_traded_notional: decimal.Decimal, average_equity: decimal.Decimal) -> alphalab.analytics.summary.TradeMetrics", + "calmar_ratio": "function alphalab.analytics.metrics.calmar_ratio(cagr_value: float | None, max_drawdown: float) -> float | None", + "capital_concentration": "function alphalab.analytics.cross_strategy.capital_concentration(amounts: 'Mapping[str, Decimal]', *, currency: 'str', dimension: 'str') -> 'CapitalConcentration'", + "capital_overlap": "function alphalab.analytics.cross_strategy.capital_overlap(allocations: 'Mapping[str, Mapping[str, Decimal]]', *, currency: 'str') -> 'tuple[SharedCapitalPool, ...]'", + "common_exposures": "function alphalab.analytics.cross_strategy.common_exposures(lines: 'Iterable[ExposureLine]', *, by: 'CommonDimension', classification: 'Classification | None') -> 'CommonExposureReport'", + "concentration": "function alphalab.analytics.decomposition.concentration(positions: 'Sequence[PositionRisk]') -> 'ConcentrationMetrics'", + "conditional_var": "function alphalab.analytics.metrics.conditional_var(returns: tuple[float, ...], confidence: float = 0.95) -> float | None", + "correlation_matrix": "function alphalab.analytics.decomposition.correlation_matrix(positions: 'Sequence[PositionRisk]') -> 'Mapping[str, Mapping[str, float]]'", + "covariance_matrix": "function alphalab.analytics.decomposition.covariance_matrix(positions: 'Sequence[PositionRisk]') -> 'Mapping[str, Mapping[str, float]]'", + "currency_loadings": "function alphalab.analytics.risk_model.currency_loadings(denominations: 'Mapping[str, str]', reporting_currency: 'str', *, as_of: 'float | None') -> 'FactorLoadings'", + "decompose": "function alphalab.analytics.decomposition.decompose(positions: 'Sequence[PositionRisk]', portfolio_returns: 'Sequence[float]', equity: 'Decimal', equity_curve: 'Sequence[float]', policy: 'VaRPolicy', benchmark: 'Sequence[float] | None' = None, loadings: 'Mapping[str, Mapping[str, float]] | None' = None, units: 'Mapping[str, Decimal] | None' = None, average_daily_volume: 'Mapping[str, Decimal] | None' = None, participation_limit: 'Decimal | None' = None) -> 'RiskDecomposition'", + "euler_decomposition": "function alphalab.analytics.risk_model.euler_decomposition(weights: 'Mapping[str, float]', covariance: 'CovarianceMatrix | FactorStructure') -> 'RiskContributions'", + "evaluate_risk_budget": "function alphalab.analytics.risk_budget.evaluate_risk_budget(lines: 'Iterable[ExposureLine]', *, capital: 'Decimal', reporting_currency: 'str', covariance: 'CovarianceMatrix', budget: 'RiskBudget', classifications: 'Sequence[Classification]') -> 'RiskBudgetReport'", + "factor_cholesky_pivots": "function alphalab.analytics.risk_model.factor_cholesky_pivots(loadings: 'Sequence[Sequence[float]]', factor_covariance: 'Sequence[Sequence[float]]', specific: 'Sequence[float]') -> 'tuple[float, ...] | None'", + "factor_crowding": "function alphalab.analytics.cross_strategy.factor_crowding(lines: 'Iterable[ExposureLine]', loadings: 'FactorLoadings', *, capital: 'Decimal') -> 'FactorCrowdingReport'", + "factor_exposure": "function alphalab.analytics.decomposition.factor_exposure(positions: 'Sequence[PositionRisk]', loadings: 'Mapping[str, Mapping[str, float]]') -> 'Mapping[str, float]'", + "factor_risk": "function alphalab.analytics.risk_model.factor_risk(weights: 'Mapping[str, float]', covariance: 'CovarianceMatrix | FactorStructure') -> 'FactorRisk'", + "geometric_return": "function alphalab.analytics.returns.geometric_return(returns: tuple[float, ...]) -> float | None", + "gross_weights": "function alphalab.analytics.decomposition.gross_weights(positions: 'Sequence[PositionRisk]') -> 'Mapping[str, float]'", + "herfindahl_index": "function alphalab.analytics.risk_model.herfindahl_index(shares: 'Iterable[float]') -> 'float'", + "latest_performance_summary": "function alphalab.analytics.views.latest_performance_summary(state: alphalab.analytics.state.AnalyticsState) -> alphalab.analytics.report.PerformanceReport | None", + "leverage": "function alphalab.analytics.decomposition.leverage(positions: 'Sequence[PositionRisk]', equity: 'Decimal') -> 'LeverageMetrics'", + "liquidity_risk": "function alphalab.analytics.decomposition.liquidity_risk(positions: 'Sequence[PositionRisk]', units: 'Mapping[str, Decimal]', average_daily_volume: 'Mapping[str, Decimal]', participation_limit: 'Decimal') -> 'LiquidityRisk'", + "percentile_loss": "function alphalab.analytics.decomposition.percentile_loss(returns: 'Sequence[float]', fraction: 'float') -> 'float'", + "portfolio_beta": "function alphalab.analytics.decomposition.portfolio_beta(portfolio_returns: 'Sequence[float]', benchmark: 'Sequence[float]') -> 'float'", + "portfolio_factor_exposures": "function alphalab.analytics.risk_model.portfolio_factor_exposures(weights: 'Mapping[str, float]', loadings: 'FactorLoadings') -> 'Mapping[str, float]'", + "portfolio_volatility": "function alphalab.analytics.decomposition.portfolio_volatility(positions: 'Sequence[PositionRisk]') -> 'float'", + "risk_contributions": "function alphalab.analytics.decomposition.risk_contributions(positions: 'Sequence[PositionRisk]') -> 'Mapping[str, float]'", + "rolling_return": "function alphalab.analytics.rolling.rolling_return(returns: tuple[float, ...], window: int) -> tuple[float, ...]", + "rolling_sharpe": "function alphalab.analytics.rolling.rolling_sharpe(returns: tuple[float, ...], window: int, risk_free_rate: float, periods: float) -> tuple[float | None, ...]", + "rolling_volatility": "function alphalab.analytics.rolling.rolling_volatility(returns: tuple[float, ...], window: int, periods: float) -> tuple[float, ...]", + "sharpe_ratio": "function alphalab.analytics.metrics.sharpe_ratio(returns: tuple[float, ...], risk_free_rate: float, periods: float) -> float | None", + "sortino_ratio": "function alphalab.analytics.metrics.sortino_ratio(returns: tuple[float, ...], risk_free_rate: float, periods: float) -> float | None", + "split_realized_pnl": "function alphalab.analytics.attribution.split_realized_pnl(realized_pnl: decimal.Decimal, contributions: collections.abc.Sequence[alphalab.core.contribution.StrategyContribution]) -> tuple[tuple[str, decimal.Decimal], ...]", + "strategy_overlap": "function alphalab.analytics.cross_strategy.strategy_overlap(lines: 'Iterable[ExposureLine]') -> 'tuple[ExposureSimilarity, ...]'", + "strategy_return_correlation": "function alphalab.analytics.cross_strategy.strategy_return_correlation(returns: 'Sequence[StrategyReturns]', *, source: 'str') -> 'StrategyCorrelation'", + "tail_ratio": "function alphalab.analytics.decomposition.tail_ratio(policy: 'VaRPolicy', returns: 'Sequence[float]') -> 'float'", + "total_reports_generated": "function alphalab.analytics.views.total_reports_generated(state: alphalab.analytics.state.AnalyticsState) -> int", + "total_return": "function alphalab.analytics.returns.total_return(start_value: decimal.Decimal, end_value: decimal.Decimal) -> float | None", + "validate_capital": "function alphalab.analytics.validation.validate_capital(capital: decimal.Decimal) -> None", + "validate_returns": "function alphalab.analytics.validation.validate_returns(returns: tuple[float, ...]) -> None", + "value_at_risk": "function alphalab.analytics.metrics.value_at_risk(returns: tuple[float, ...], confidence: float = 0.95) -> float | None" + }, + "alphalab.api": { + "AnalyticsEngine": "class alphalab.analytics.engine.AnalyticsEngine()", + "AvailabilityAfterLag": "class alphalab.api.AvailabilityAfterLag(lag_seconds: 'float') -> None", + "AvailabilityAtNextOpen": "class alphalab.api.AvailabilityAtNextOpen(column: 'str', calendar: 'SessionCalendar') -> None", + "AvailabilityFromColumn": "class alphalab.api.AvailabilityFromColumn(column: 'str') -> None", + "AvailabilityNotDeclared": "class alphalab.api.AvailabilityNotDeclared() -> None", + "AvailabilityRule": "alias AvailabilityRule", + "CombinedDataset": "class alphalab.api.CombinedDataset(parts: 'tuple[tuple[Dataset, NormalizationPolicy], ...]') -> None", + "CombinedProvenance": "class alphalab.api.CombinedProvenance(dataset_version: 'str', content_hash: 'str', source: 'CombinedSourceBytes', parts: 'tuple[tuple[str, str], ...]') -> None", + "CombinedSourceBytes": "class alphalab.api.CombinedSourceBytes(byte_count: 'int') -> None", + "DataRequest": "class alphalab.api.DataRequest(symbols: 'tuple[str, ...]' = (), start: 'float | None' = None, end: 'float | None' = None, price_basis: 'PriceBasis | None' = None, as_of: 'float | None' = None) -> None", + "DataSelection": "class alphalab.api.DataSelection(records: 'tuple[CanonicalRecord, ...]', dataset_version: 'str | None', request: 'DataRequest') -> None", + "EventColumns": "class alphalab.api.EventColumns(subject: 'str', event_type: 'str', observed_at: 'str', availability: 'AvailabilityRule', measurements: 'tuple[str, ...]', attributes: 'tuple[str, ...]', effective_at: 'str | None', revision: 'str | None', ingested_at_retrieval: 'bool') -> None", + "FundamentalColumns": "class alphalab.api.FundamentalColumns(subject: 'str', statement: 'str', line_item: 'str', fiscal_year: 'str', fiscal_quarter: 'str', period_start: 'str', period_end: 'str', value: 'str', unit: 'str', published_at: 'str', availability: 'AvailabilityRule', revision: 'str | None', ingested_at_retrieval: 'bool') -> None", + "ObservationColumns": "class alphalab.api.ObservationColumns(category: 'str', unit: 'str', subject: 'str', metric: 'str', value: 'str', observed_at: 'str', availability: 'AvailabilityRule', effective_at: 'str | None', revision: 'str | None', period: 'tuple[str, str, str] | None', ingested_at_retrieval: 'bool') -> None", + "PointInTimeIngestion": "class alphalab.api.PointInTimeIngestion(observations: 'ObservationSet[T]', rejected: 'tuple[RowRejection, ...]') -> None", + "ResearchEngine": "class alphalab.research.engine.ResearchEngine()", + "ResearchStudy": "class alphalab.research.study.ResearchStudy(study_name: 'str', dataset_version: 'str | None', universe: 'tuple[str, ...]', features: 'tuple[FeatureDefinition, ...]', horizons: 'tuple[int, ...]' = (), splits: 'str' = '', parameters: 'Mapping[str, float]' = , seed: 'int | None' = None, notes: 'str' = '', inputs: 'Mapping[str, str]' = , implementation_lag: 'int | None' = None) -> None", + "StudyResult": "class alphalab.research.study.StudyResult(result_id: 'str', study: 'ResearchStudy', metrics: 'Mapping[str, float]' = , splits: 'SplitReport | None' = None, feature_lineage: 'Mapping[str, str]' = , findings: 'tuple[str, ...]' = , warnings: 'tuple[str, ...]' = , produced_at: 'float' = 0.0) -> None", + "TimestampReading": "class alphalab.api.TimestampReading(timestamp_format: 'TimestampFormat', timezone_name: 'str | None', date_policy: 'DateOnlyPolicy | None') -> None", + "WireTimestamp": "enum alphalab.api.WireTimestamp[AVAILABILITY, OBSERVATION]", + "backtest": "function alphalab.api.backtest(config: 'RunConfig', dataset: 'Dataset', strategy_state: 'StrategyRuntimeState', context_factory: 'ContextFactory', policy: 'NormalizationPolicy', *, observations: 'Iterable[ObservationDelivery[Any]]' = (), schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), execution: 'ExecutionPlan | None' = None, queue: 'QueueModel | None' = None) -> 'BacktestResult'", + "bond_schedule": "function alphalab.api.bond_schedule(bond: 'Bond', instrument: 'InstrumentRecord', at: 'Callable[[date], float]', *, redemption_price: 'Decimal') -> 'RunSchedule'", + "clean_dataset": "function alphalab.api.clean_dataset(dataset: 'Dataset', policy: 'CleaningPolicy') -> 'tuple[tuple[CanonicalRecord, ...], DataQualityReport]'", + "combine_datasets": "function alphalab.api.combine_datasets(parts: 'Sequence[tuple[Dataset, NormalizationPolicy]]') -> 'CombinedDataset'", + "combined_market_dataset": "function alphalab.api.combined_market_dataset(combined: 'CombinedDataset') -> 'MarketDataset'", + "convention_from_spec": "function alphalab.api.convention_from_spec(spec: 'EquitySpec | FutureSpec | OptionSpec', calendar_id: 'str', tick: 'TickSchedule', lot: 'LotSpecification', settlement: 'SettlementRule', settlement_currency: 'str | None' = None) -> 'MarketConvention'", + "currency_classification": "function alphalab.api.currency_classification(registry: 'InstrumentRegistry', asset_ids: 'Sequence[str]') -> 'Classification'", + "future_contract_from_spec": "function alphalab.api.future_contract_from_spec(spec: 'FutureSpec', currency: 'str | None' = None) -> 'FutureContract'", + "ingest_csv": "function alphalab.api.ingest_csv(path: 'str | Path', request: 'IngestionRequest', encoding: 'str' = 'utf-8', dialect: 'CsvDialect | None' = None) -> 'IngestionResult'", + "ingest_events": "function alphalab.api.ingest_events(rows: 'Sequence[Mapping[str, object]]', name: 'str', source: 'ObservationSource', columns: 'EventColumns', reading: 'TimestampReading') -> 'PointInTimeIngestion[InformationEvent]'", + "ingest_fundamentals": "function alphalab.api.ingest_fundamentals(rows: 'Sequence[Mapping[str, object]]', name: 'str', source: 'ObservationSource', columns: 'FundamentalColumns', reading: 'TimestampReading') -> 'PointInTimeIngestion[FundamentalObservation]'", + "ingest_observations": "function alphalab.api.ingest_observations(rows: 'Sequence[Mapping[str, object]]', name: 'str', source: 'ObservationSource', columns: 'ObservationColumns', reading: 'TimestampReading') -> 'PointInTimeIngestion[ExternalObservation]'", + "ingest_rows": "function alphalab.api.ingest_rows(rows: 'Sequence[Mapping[str, object]]', request: 'IngestionRequest') -> 'IngestionResult'", + "inspect_csv": "function alphalab.api.inspect_csv(path: 'str | Path', retrieved_at: 'float', encoding: 'str' = 'utf-8', dialect: 'CsvDialect | None' = None) -> 'tuple[RawSource, RawTable, SchemaDetection]'", + "lift_wire_records": "function alphalab.api.lift_wire_records(records: 'Sequence[CanonicalRecord]', name: 'str', source: 'ObservationSource', meaning: 'WireTimestamp', unit: 'str') -> 'ObservationSet[ExternalObservation]'", + "normalize_records": "function alphalab.api.normalize_records(records: 'Sequence[CanonicalRecord]', policy: 'NormalizationPolicy') -> 'tuple[MarketInput, ...]'", + "observation_source": "function alphalab.api.observation_source(raw: 'RawSource', source_id: 'str', version: 'str | None', quality: 'DataProvenance | None' = None) -> 'ObservationSource'", + "observations_from_indicator": "function alphalab.api.observations_from_indicator(metadata: 'IndicatorMetadata', releases: 'Sequence[IndicatorObservation]', source: 'ObservationSource', *, name: 'str | None' = None) -> 'ObservationSet[ExternalObservation]'", + "observe": "function alphalab.api.observe(dataset: 'Dataset', source_field: 'FeatureField') -> 'ObservationFrame'", + "option_contract_from_spec": "function alphalab.api.option_contract_from_spec(spec: 'OptionSpec') -> 'OptionContract'", + "replay": "function alphalab.api.replay(config: 'RunConfig', dataset: 'Dataset', strategy_state: 'StrategyRuntimeState', context_factory: 'ContextFactory', policy: 'NormalizationPolicy', *, pause_when: 'Callable[[ReplayProgress], bool] | None' = None, observations: 'Iterable[ObservationDelivery[Any]]' = (), schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), execution: 'ExecutionPlan | None' = None, queue: 'QueueModel | None' = None) -> 'ReplayResult | PausedReplay'", + "resume_replay": "function alphalab.api.resume_replay(paused: 'PausedReplay', context_factory: 'ContextFactory', *, pause_when: 'Callable[[ReplayProgress], bool] | None' = None) -> 'ReplayResult | PausedReplay'", + "run_study": "function alphalab.api.run_study(study: 'ResearchStudy', dataset: 'Dataset', price_field: 'FeatureField' = , buckets: 'int' = 5, minimum_assets: 'int' = 5, produced_at: 'float' = 0.0, *, delistings: 'Sequence[DelistingReturn]') -> 'StudyResult'", + "sector_classification": "function alphalab.api.sector_classification(registry: 'InstrumentRegistry', asset_ids: 'Sequence[str]', as_of: 'float | None') -> 'Classification'", + "select": "function alphalab.api.select(dataset: 'Dataset', request: 'DataRequest') -> 'DataSelection'", + "study_panels": "function alphalab.api.study_panels(study: 'ResearchStudy', dataset: 'Dataset') -> 'dict[str, FeaturePanel]'", + "to_market_dataset": "function alphalab.api.to_market_dataset(dataset: 'Dataset', policy: 'NormalizationPolicy') -> 'MarketDataset'", + "validate_dataset": "function alphalab.api.validate_dataset(dataset: 'Dataset') -> 'tuple[ValidationFinding, ...]'" + }, + "alphalab.backtesting": { + "AlgorithmAssignment": "class alphalab.backtesting.algorithmic.AlgorithmAssignment(algorithm: 'ExecutionAlgorithm', window_seconds: 'float', quantity_increment: 'Decimal') -> None", + "AlgorithmBook": "class alphalab.backtesting.algorithmic.AlgorithmBook(plan: 'ExecutionPlan', working: 'PersistentMap[str, AlgorithmState]' = , finished: 'PersistentMap[str, FinishedParent]' = , fills: 'AppendOnlyLog[ChildFill]' = ) -> None", + "AlgorithmicResult": "class alphalab.backtesting.state.AlgorithmicResult(run: 'RunState', algorithms: 'AlgorithmBook') -> None", + "BacktestEngine": "class alphalab.backtesting.engine.BacktestEngine()", + "BacktestError": "class alphalab.backtesting.exceptions.BacktestError", + "BacktestResult": "class alphalab.backtesting.state.BacktestResult(run: 'RunState') -> None", + "ChildFill": "class alphalab.backtesting.algorithmic.ChildFill(child_id: 'str', parent_order_id: 'str', execution_id: 'str', quantity: 'Decimal', price: 'Decimal', commission: 'Decimal', timestamp: 'float') -> None", + "DatasetValidationError": "class alphalab.backtesting.exceptions.DatasetValidationError", + "EMPTY_SCHEDULE": "value alphalab.backtesting.schedule.RunSchedule", + "ExecutionMode": "enum alphalab.runtime.run.ExecutionMode[BACKTEST, REPLAY, PAPER, LIVE]", + "ExecutionPlan": "class alphalab.backtesting.algorithmic.ExecutionPlan(default: 'AlgorithmAssignment | None' = None, by_asset: 'tuple[tuple[str, AlgorithmAssignment], ...]' = (), capabilities: 'VenueCheck | None' = None) -> None", + "ExecutionPlanError": "class alphalab.backtesting.algorithmic.ExecutionPlanError", + "FillDecision": "class alphalab.execution.policy.FillDecision(status: alphalab.execution.fill.FillStatus, quantity: decimal.Decimal | None = None) -> None", + "FillPolicy": "class alphalab.execution.policy.FillPolicy(*args, **kwargs)", + "FinishedParent": "class alphalab.backtesting.algorithmic.FinishedParent(order_id: 'str', status: 'OrderStatus', at: 'float', reason: 'str', algorithm: 'AlgorithmState | None') -> None", + "Funding": "class alphalab.backtesting.schedule.Funding(amount: 'Decimal', currency: 'str', at: 'float') -> None", + "ImmediateFill": "class alphalab.execution.policy.ImmediateFill() -> None", + "LiquidityCappedFill": "class alphalab.execution.policy.LiquidityCappedFill(participation_rate: decimal.Decimal = Decimal('1')) -> None", + "LiquidityContext": "class alphalab.execution.policy.LiquidityContext(asset_id: str, side: alphalab.core.enums.Side, requested_quantity: decimal.Decimal, price: decimal.Decimal, available_quantity: decimal.Decimal | None, timestamp: float) -> None", + "MarketDataset": "class alphalab.backtesting.dataset.MarketDataset(dataset_id: 'str', records: 'tuple[MarketRecord, ...]') -> None", + "MarketInput": "alias alphalab.market.quote.Quote | alphalab.market.bar.Bar | alphalab.market.tick.Tick", + "MarketRecord": "class alphalab.market.record.MarketRecord(event_id: 'str', timestamp: 'float', payload: 'MarketInput') -> None", + "PausedReplay": "class alphalab.backtesting.replay.PausedReplay(run: 'RunState', cursor: 'ReplayState', cursor_position: 'IdStreamPosition', schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), algorithms: 'AlgorithmBook | None' = None, queue: 'QueueBook | None' = None) -> None", + "PriceTimeQueue": "class alphalab.execution.queue.PriceTimeQueue(count_unsigned_trades: 'bool' = False) -> None", + "QueueBook": "class alphalab.execution.queue.QueueBook(model: 'QueueModel', positions: 'PersistentMap[str, QueuePosition]' = ) -> None", + "QueueModel": "class alphalab.execution.queue.QueueModel(*args, **kwargs)", + "QueueModelError": "class alphalab.backtesting.queueing.QueueModelError", + "QueuedResult": "class alphalab.backtesting.state.QueuedResult(run: 'RunState', queue: 'QueueBook') -> None", + "ReplayBacktest": "class alphalab.backtesting.replay.ReplayBacktest()", + "ReplayProgress": "class alphalab.backtesting.replay.ReplayProgress(records_replayed: 'int', records_total: 'int', next_timestamp: 'float') -> None", + "ReplayResult": "class alphalab.backtesting.state.ReplayResult(backtest: 'BacktestResult', replay_status: 'str', records_replayed: 'int', last_record: 'MarketRecord | None') -> None", + "RunConfig": "class alphalab.runtime.run.RunConfig(pipeline: 'ExecutionPipelineConfig', mode: 'ExecutionMode', fill_policy: 'FillPolicy' = , seed: 'int | None' = None, start_timestamp: 'float' = 0.0, ordering: 'OrderingGuarantee' = , max_market_data_age_seconds: 'float | None' = None, years_elapsed: 'float | None' = None, risk_free_rate: 'float' = 0.0, compile_analytics: 'bool' = True, periods_per_year: 'float | None' = None, halt_on_strategy_failure: 'bool' = False) -> None", + "RunSchedule": "class alphalab.backtesting.schedule.RunSchedule(entries: 'tuple[ScheduleEntry, ...]' = (), rate_max_age_seconds: 'float | None' = None) -> None", + "RunState": "class alphalab.runtime.run.RunState(config: 'RunConfig', pipeline: 'ExecutionPipelineState', processed: 'int' = 0, current_timestamp: 'float' = 0.0, last_record_timestamp: 'float | None' = None, source_id: 'str | None' = None, steps: 'AppendOnlyLog[RunStep]' = , skipped: 'AppendOnlyLog[SkippedRecord]' = , last_slice_at: 'float | None' = None, observations_delivered: 'int' = 0, last_observation: 'tuple[float, str] | None' = None) -> None", + "RunStep": "class alphalab.runtime.run.RunStep(index: 'int', event_id: 'str', timestamp: 'float', orders: 'tuple[OMSOrder, ...]', reports: 'tuple[ExecutionReport, ...]', fills: 'tuple[CoreFill, ...]', equity: 'Decimal') -> None", + "ScheduleCursor": "class alphalab.backtesting.schedule.ScheduleCursor(schedule: 'RunSchedule', position: 'int', rates: 'FxRates') -> None", + "ScheduleValidationError": "class alphalab.backtesting.schedule.ScheduleValidationError", + "ScheduledAssignment": "class alphalab.backtesting.schedule.ScheduledAssignment(assignment: 'Assignment', at: 'float') -> None", + "ScheduledCashFlow": "class alphalab.backtesting.schedule.ScheduledCashFlow(flow: 'CashFlow', at: 'float') -> None", + "ScheduledDistribution": "class alphalab.backtesting.schedule.ScheduledDistribution(asset_id: 'str', per_unit: 'Decimal', currency: 'str', at: 'float', kind: 'CashFlowKind' = , reference: 'str' = '') -> None", + "ScheduledExercise": "class alphalab.backtesting.schedule.ScheduledExercise(exercise: 'Exercise', at: 'float') -> None", + "ScheduledExpiry": "class alphalab.backtesting.schedule.ScheduledExpiry(expiry: 'OptionExpiry', at: 'float') -> None", + "ScheduledFundingRate": "class alphalab.backtesting.schedule.ScheduledFundingRate(asset_id: 'str', rate: 'Decimal', at: 'float', reference: 'str' = '') -> None", + "ScheduledSettlement": "class alphalab.backtesting.schedule.ScheduledSettlement(settlement: 'ContractSettlement', at: 'float') -> None", + "ScheduledSplit": "class alphalab.backtesting.schedule.ScheduledSplit(split: 'Split', at: 'float') -> None", + "StaticFill": "class alphalab.execution.policy.StaticFill(status: alphalab.execution.fill.FillStatus = , quantity: decimal.Decimal | None = None) -> None", + "UnpricedAsset": "class alphalab.runtime.execution_pipeline.UnpricedAsset(asset_id: 'str', reason: 'UnpricedReason', detail: 'str', first_timestamp: 'float', last_timestamp: 'float', occurrences: 'int') -> None", + "UnpricedReason": "enum alphalab.runtime.execution_pipeline.UnpricedReason[NO_PRICE_OBSERVED, NOT_REGISTERED, REGISTERED_BUT_UNPRICED]", + "UnsupportedRecordError": "class alphalab.market.exceptions.UnsupportedRecordError", + "VenueCheck": "class alphalab.backtesting.algorithmic.VenueCheck(declaration: 'CapabilityDeclaration', account_id: 'str', features: 'frozenset[Capability]' = frozenset()) -> None", + "advance": "function alphalab.backtesting.engine.advance(state: 'RunState', record: 'MarketRecord', context_factory: 'ContextFactory') -> 'tuple[RunState, ExecutionPipelineResult | None]'", + "commission_paid": "function alphalab.backtesting.views.commission_paid(result: alphalab.backtesting.state.BacktestResult) -> decimal.Decimal", + "equity_values": "function alphalab.backtesting.views.equity_values(result: alphalab.backtesting.state.BacktestResult) -> tuple[decimal.Decimal, ...]", + "executed_fills": "function alphalab.backtesting.views.executed_fills(result: alphalab.backtesting.state.BacktestResult) -> collections.abc.Sequence[alphalab.core.fill.Fill]", + "final_cash": "function alphalab.backtesting.views.final_cash(result: alphalab.backtesting.state.BacktestResult) -> decimal.Decimal", + "final_equity": "function alphalab.backtesting.views.final_equity(result: alphalab.backtesting.state.BacktestResult) -> decimal.Decimal", + "finalize": "function alphalab.backtesting.engine.finalize(state: 'RunState') -> 'BacktestResult'", + "id_scope": "function alphalab.common.ids.id_scope(seed: int | None) -> contextlib.AbstractContextManager[None]", + "id_source": "function alphalab.common.ids.id_source(seed: int | None) -> collections.abc.Callable[[], str] | None", + "initialize": "function alphalab.backtesting.engine.initialize(config: 'RunConfig', strategy_state: 'StrategyRuntimeState') -> 'RunState'", + "performance_report": "function alphalab.backtesting.views.performance_report(result: alphalab.backtesting.state.BacktestResult) -> alphalab.analytics.report.PerformanceReport | None", + "publish": "function alphalab.backtesting.engine.publish(market: 'MarketState', record: 'MarketRecord') -> 'MarketState'", + "realized_pnl": "function alphalab.backtesting.views.realized_pnl(result: alphalab.backtesting.state.BacktestResult) -> decimal.Decimal", + "session_for": "function alphalab.backtesting.replay.session_for(dataset: 'MarketDataset', speed_multiplier: 'float' = 1.0) -> 'ReplaySession'", + "steps_with_fills": "function alphalab.backtesting.views.steps_with_fills(result: alphalab.backtesting.state.BacktestResult) -> collections.abc.Sequence[alphalab.runtime.run.RunStep]", + "submitted_orders": "function alphalab.backtesting.views.submitted_orders(result: alphalab.backtesting.state.BacktestResult) -> collections.abc.Sequence[alphalab.oms.order.Order]", + "unrealized_pnl": "function alphalab.backtesting.views.unrealized_pnl(result: alphalab.backtesting.state.BacktestResult) -> decimal.Decimal", + "validate_dataset": "function alphalab.backtesting.dataset.validate_dataset(dataset: 'MarketDataset') -> 'None'" + }, + "alphalab.backtesting.replay_snapshot": { + "REPLAY_SCHEMA_HISTORY": "value alphalab.persistence.upgrade.SchemaHistory", + "REPLAY_SNAPSHOT_SCHEMA": "value builtins.int", + "ReplayEventRecord": "class alphalab.backtesting.replay_snapshot.ReplayEventRecord(event_type: 'str', event: 'ReplaySystemEvent') -> None", + "ReplaySnapshot": "class alphalab.backtesting.replay_snapshot.ReplaySnapshot(run: 'RunSnapshot', session: 'ReplaySession', status: 'ReplayStatus', current_index: 'int', current_timestamp: 'float', real_start_time: 'float', real_current_time: 'float', events: 'tuple[ReplayEventRecord, ...]', cursor_position: 'IdStreamPosition', records_total: 'int', dataset_digest: 'str', last_event_id: 'str | None', next_event_id: 'str', events_dropped: 'int' = 0, schedule_digest: 'str | None' = None, algorithms: 'Mapping[str, Any] | None' = None, queue: 'Mapping[str, Any] | None' = None, schema_version: 'int' = 2) -> None", + "capture": "function alphalab.backtesting.replay_snapshot.capture(paused: 'PausedReplay') -> 'ReplaySnapshot'", + "dataset_digest": "function alphalab.backtesting.replay_snapshot.dataset_digest(dataset: 'MarketDataset') -> 'str'", + "from_primitives": "function alphalab.backtesting.replay_snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'ReplaySnapshot'", + "restore": "function alphalab.backtesting.replay_snapshot.restore(snapshot: 'ReplaySnapshot', objects: 'RunObjects', dataset: 'MarketDataset', schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), execution: 'ExecutionPlan | None' = None, queue: 'QueueModel | None' = None) -> 'PausedReplay'" + }, + "alphalab.broker": { + "ANY_LISTING_VENUE": "value builtins.str", + "AccountCapability": "class alphalab.core.capabilities.AccountCapability(account_id: 'str', asset_classes: 'frozenset[AssetType]', margin: 'Support', short_selling: 'Support') -> None", + "BROKER_LOCAL_EQUIVALENTS": "value builtins.mappingproxy", + "BROKER_STATUS_EQUIVALENTS": "value builtins.mappingproxy", + "BrokerAccount": "class alphalab.broker.account.BrokerAccount(account_id: str, cash: decimal.Decimal, equity: decimal.Decimal, buying_power: decimal.Decimal, margin: decimal.Decimal, available_funds: decimal.Decimal, currency: str, broker_id: str = '', metadata: collections.abc.Mapping[str, str] = ) -> None", + "BrokerAdapter": "class alphalab.broker.adapter.BrokerAdapter()", + "BrokerAssetClass": "enum alphalab.core.enums.AssetType[EQUITY, FUTURE, OPTION, FOREX, CRYPTO, CASH, BOND, INDEX]", + "BrokerConnected": "class alphalab.broker.events.BrokerConnected(event_id: str, timestamp: float, broker_name: str) -> None", + "BrokerDisconnected": "class alphalab.broker.events.BrokerDisconnected(event_id: str, timestamp: float, broker_name: str, reason: str) -> None", + "BrokerEngine": "class alphalab.broker.broker.BrokerEngine()", + "BrokerError": "class alphalab.broker.exceptions.BrokerError", + "BrokerEvent": "class alphalab.broker.events.BrokerEvent(event_id: str, timestamp: float) -> None", + "BrokerExecution": "class alphalab.broker.execution.BrokerExecution(execution_id: str, broker_order_id: str, symbol: str, fill_quantity: decimal.Decimal, fill_price: decimal.Decimal, commission: decimal.Decimal, timestamp: float, account_id: str = '', external_id: str = '') -> None", + "BrokerOrder": "class alphalab.broker.order.BrokerOrder(broker_order_id: str, oms_order_id: str, symbol: str, side: alphalab.core.enums.Side, order_type: alphalab.common.order_terms.OrderType, quantity: decimal.Decimal, price: decimal.Decimal, filled_quantity: decimal.Decimal, average_fill_price: decimal.Decimal, status: alphalab.core.enums.OrderStatus | alphalab.broker.order.BrokerOrderStatus, created_at: float, updated_at: float, account_id: str = '', tif: alphalab.common.order_terms.TimeInForce = , stop_price: decimal.Decimal = Decimal('0')) -> None", + "BrokerOrderSide": "enum alphalab.core.enums.Side[BUY, SELL]", + "BrokerOrderStatus": "enum alphalab.broker.order.BrokerOrderStatus[PENDING_SUBMIT, SUBMITTED, PENDING_CANCEL]", + "BrokerOrderType": "enum alphalab.common.order_terms.OrderType[MARKET, LIMIT, STOP, STOP_LIMIT]", + "BrokerPosition": "class alphalab.broker.position.BrokerPosition(symbol: str, quantity: decimal.Decimal, average_price: decimal.Decimal, market_value: decimal.Decimal, unrealized_pnl: decimal.Decimal, realized_pnl: decimal.Decimal, account_id: str = '', asset_class: alphalab.core.enums.AssetType = , market_price: decimal.Decimal = Decimal('0')) -> None", + "BrokerProtocol": "class alphalab.broker.protocol.BrokerProtocol(*args, **kwargs)", + "BrokerState": "class alphalab.broker.state.BrokerState(broker_name: str, connection_status: alphalab.broker.state.ConnectionStatus, account: alphalab.broker.account.BrokerAccount, positions: alphalab.common.persistent_map.PersistentMap[str, alphalab.broker.position.BrokerPosition] = , orders: alphalab.common.persistent_map.PersistentMap[str, alphalab.broker.order.BrokerOrder] = , executions: alphalab.common.persistent_map.PersistentMap[str, alphalab.broker.execution.BrokerExecution] = , events: alphalab.common.append_log.AppendOnlyLog[alphalab.broker.events.BrokerEvent] = , metadata: collections.abc.Mapping[str, str] = , last_heartbeat: float = 0.0, venue_sequences: alphalab.common.persistent_map.PersistentMap[str, int] = ) -> None", + "BrokerTimeInForce": "enum alphalab.common.order_terms.TimeInForce[DAY, GTC, IOC, FOK, GTD, OPG, CLS]", + "BrokerValidationError": "class alphalab.broker.exceptions.BrokerValidationError", + "CancelRequest": "class alphalab.broker.requests.CancelRequest(broker_order_id: 'str', attempt: 'int', requested_at: 'float') -> None", + "Capability": "enum alphalab.core.capabilities.Capability[MARKET_ORDERS, LIMIT_ORDERS, STOP_ORDERS, STOP_LIMIT_ORDERS, SHORTING, OPTIONS, FUTURES, FRACTIONAL, MARGIN, STREAMING, CANCEL_REPLACE, BRACKET_ORDERS, EXTENDED_HOURS]", + "CapabilityCheck": "class alphalab.core.capabilities.CapabilityCheck(dimension: 'RequirementDimension', level: 'CapabilityLevel', requirement: 'str', capability: 'Capability | None', support: 'Support', detail: 'str') -> None", + "CapabilityDeclaration": "class alphalab.core.capabilities.CapabilityDeclaration(adapter_id: 'str', supported_features: 'frozenset[Capability]', unsupported_features: 'frozenset[Capability]', markets: 'tuple[MarketCapability, ...]', unsupported_asset_classes: 'frozenset[AssetType]', accounts: 'tuple[AccountCapability, ...]') -> None", + "CapabilityLevel": "enum alphalab.core.capabilities.CapabilityLevel[VENUE, MARKET, ACCOUNT]", + "Compatibility": "enum alphalab.core.capabilities.Compatibility[COMPATIBLE, INCOMPATIBLE, UNDETERMINED]", + "CompatibilityReport": "class alphalab.core.capabilities.CompatibilityReport(requirements: 'ExecutionRequirements', adapter_id: 'str', declaration_id: 'str', checks: 'tuple[CapabilityCheck, ...]') -> None", + "ConnectionStatus": "enum alphalab.broker.state.ConnectionStatus[DISCONNECTED, CONNECTING, CONNECTED, RECONNECTING, FAILED]", + "ExecutionDecision": "class alphalab.broker.reconciliation.ExecutionDecision(outcome: 'ExecutionOutcome', execution: 'BrokerExecution', reason: 'str') -> None", + "ExecutionEventKind": "enum alphalab.core.lifecycle.ExecutionEventKind[ORDER_ACCEPTED, ORDER_REJECTED, ORDER_PARTIALLY_FILLED, ORDER_FILLED, ORDER_CANCELLED, ORDER_EXPIRED, ORDER_REPLACED, ORDER_CANCEL_REJECTED, POSITION_CHANGED, BALANCE_CHANGED, BROKER_DISCONNECTED, BROKER_CONNECTED]", + "ExecutionOutcome": "enum alphalab.broker.reconciliation.ExecutionOutcome[APPLIED, DUPLICATE, UNKNOWN_ORDER, TERMINAL_ORDER, OVERFILL, INVALID]", + "ExecutionReceived": "class alphalab.broker.events.ExecutionReceived(event_id: str, timestamp: float, execution_id: str, broker_order_id: str, fill_quantity: decimal.Decimal, fill_price: decimal.Decimal) -> None", + "ExecutionRequirements": "class alphalab.core.capabilities.ExecutionRequirements(asset_class: 'AssetType', listing_venue: 'str', account_id: 'str', order_types: 'frozenset[OrderType]', time_in_force: 'frozenset[TimeInForce]', short_selling: 'bool', fractional_quantities: 'bool', extended_hours: 'bool', bracket_orders: 'bool', margin: 'bool', features: 'frozenset[Capability]') -> None", + "ExternalOrderMap": "class alphalab.broker.reconciliation.ExternalOrderMap(to_broker: 'PersistentMap[str, str]' = , to_oms: 'PersistentMap[str, str]' = ) -> None", + "Heartbeat": "class alphalab.broker.events.Heartbeat(event_id: str, timestamp: float, broker_name: str) -> None", + "InvalidBrokerStateError": "class alphalab.broker.exceptions.InvalidBrokerStateError", + "LifecycleDecision": "class alphalab.broker.lifecycle.LifecycleDecision(event: 'VenueEvent', outcome: 'LifecycleOutcome', reason: 'str', status_before: 'OrderStatus | BrokerOrderStatus | None' = None, status_after: 'OrderStatus | BrokerOrderStatus | None' = None, implied: 'tuple[ExecutionEventKind, ...]' = (), execution: 'ExecutionDecision | None' = None) -> None", + "LifecycleOutcome": "enum alphalab.broker.lifecycle.LifecycleOutcome[APPLIED, DUPLICATE, STALE, CONFLICT, UNKNOWN_ORDER, INVALID]", + "MarketCapability": "class alphalab.core.capabilities.MarketCapability(asset_class: 'AssetType', listing_venue: 'str', order_types: 'frozenset[OrderType]', time_in_force: 'frozenset[TimeInForce]', short_selling: 'Support', fractional_quantities: 'Support', extended_hours: 'Support', bracket_orders: 'Support') -> None", + "ModifyRequest": "class alphalab.broker.requests.ModifyRequest(broker_order_id: 'str', revision: 'int', quantity: 'Decimal', price: 'Decimal', requested_at: 'float') -> None", + "OMSOrderProtocol": "class alphalab.broker.adapter.OMSOrderProtocol(*args, **kwargs)", + "OrderAccepted": "class alphalab.broker.events.OrderAccepted(event_id: str, timestamp: float, broker_order_id: str) -> None", + "OrderCancelled": "class alphalab.broker.events.OrderCancelled(event_id: str, timestamp: float, broker_order_id: str) -> None", + "OrderDivergence": "class alphalab.broker.reconciliation.OrderDivergence(broker_order_id: 'str', local: 'BrokerOrder | None', remote: 'BrokerOrder | None', reason: 'str') -> None", + "OrderRejected": "class alphalab.broker.events.OrderRejected(event_id: str, timestamp: float, broker_order_id: str, reason: str) -> None", + "OrderSubmitted": "class alphalab.broker.events.OrderSubmitted(event_id: str, timestamp: float, broker_order_id: str, oms_order_id: str) -> None", + "PaperBroker": "class alphalab.broker.paper.PaperBroker(cost_model: alphalab.execution.costs.ExecutionCostModel) -> None", + "PositionDivergence": "class alphalab.broker.reconciliation.PositionDivergence(symbol: 'str', local_quantity: 'Decimal', remote_quantity: 'Decimal') -> None", + "ReconciliationLog": "class alphalab.broker.reconciliation.ReconciliationLog(breaks: 'AppendOnlyLog[ExecutionDecision]' = , duplicates: 'AppendOnlyLog[ExecutionDecision]' = ) -> None", + "ReconciliationReport": "class alphalab.broker.reconciliation.ReconciliationReport(missing_at_broker: 'tuple[OrderDivergence, ...]' = , unknown_locally: 'tuple[OrderDivergence, ...]' = , divergent_orders: 'tuple[OrderDivergence, ...]' = , divergent_positions: 'tuple[PositionDivergence, ...]' = , cash_difference: 'Decimal' = Decimal('0')) -> None", + "RequestDecision": "class alphalab.broker.requests.RequestDecision(request: 'CancelRequest | ModifyRequest', outcome: 'RequestOutcome', reason: 'str') -> None", + "RequestLedger": "class alphalab.broker.requests.RequestLedger(issued: 'PersistentMap[str, CancelRequest | ModifyRequest]' = , latest_cancel: 'PersistentMap[str, tuple[int, str]]' = , latest_revision: 'PersistentMap[str, tuple[int, str]]' = ) -> None", + "RequestOutcome": "enum alphalab.broker.requests.RequestOutcome[NEW, DUPLICATE, REFUSED]", + "RequirementDimension": "enum alphalab.core.capabilities.RequirementDimension[MARKET, ORDER_TYPE, TIME_IN_FORCE, EXECUTION_FEATURE, ACCOUNT, RISK]", + "SNAPSHOT_RECONCILIATION_SCHEME": "value builtins.str", + "SnapshotDivergence": "class alphalab.broker.reconciliation.SnapshotDivergence(kind: 'SnapshotDivergenceKind', key: 'str', local: 'str | None', remote: 'str | None', reason: 'str') -> None", + "SnapshotDivergenceKind": "enum alphalab.broker.reconciliation.SnapshotDivergenceKind[MISSING_ORDER, UNKNOWN_ORDER, ORDER_QUANTITY, ORDER_PRICE, ORDER_FILLED_QUANTITY, ORDER_STATUS, MISSING_EXECUTION, UNKNOWN_EXECUTION, EXECUTION_MISMATCH, POSITION_QUANTITY, BALANCE, ACCOUNT_IDENTITY, DUPLICATE_EVIDENCE]", + "SnapshotFreshness": "enum alphalab.broker.reconciliation.SnapshotFreshness[CURRENT, PREDATES_MIRROR, TOO_OLD, FROM_THE_FUTURE]", + "SnapshotReconciliation": "class alphalab.broker.reconciliation.SnapshotReconciliation(snapshot_id: 'str', as_of: 'float', evaluated_at: 'float', max_age_seconds: 'float', freshness: 'SnapshotFreshness', divergences: 'tuple[SnapshotDivergence, ...]', unexamined: 'tuple[str, ...]', compared_orders: 'int', compared_executions: 'int', compared_positions: 'int') -> None", + "Support": "enum alphalab.core.capabilities.Support[SUPPORTED, UNSUPPORTED, UNDECLARED]", + "VENUE_REQUEST_SCHEME": "value builtins.str", + "VENUE_SNAPSHOT_SCHEME": "value builtins.str", + "VenueEvent": "class alphalab.broker.lifecycle.VenueEvent(kind: 'ExecutionEventKind', timestamp: 'float', broker_order_id: 'str' = '', execution: 'BrokerExecution | None' = None, quantity: 'Decimal | None' = None, price: 'Decimal | None' = None, position: 'BrokerPosition | None' = None, account: 'BrokerAccount | None' = None, reason: 'str' = '', sequence: 'int | None' = None) -> None", + "VenueSnapshot": "class alphalab.broker.reconciliation.VenueSnapshot(as_of: 'float', orders: 'tuple[BrokerOrder, ...]', executions: 'tuple[BrokerExecution, ...] | None', positions: 'tuple[BrokerPosition, ...] | None', account: 'BrokerAccount | None') -> None", + "account_snapshot": "function alphalab.broker.views.account_snapshot(state: alphalab.broker.state.BrokerState) -> alphalab.broker.account.BrokerAccount", + "apply_execution": "function alphalab.broker.reconciliation.apply_execution(state: 'BrokerState', execution: 'BrokerExecution', log: 'ReconciliationLog | None' = None) -> 'tuple[BrokerState, ExecutionDecision, ReconciliationLog]'", + "apply_venue_event": "function alphalab.broker.lifecycle.apply_venue_event(state: 'BrokerState', event: 'VenueEvent') -> 'tuple[BrokerState, LifecycleDecision]'", + "apply_venue_events": "function alphalab.broker.lifecycle.apply_venue_events(state: 'BrokerState', events: 'Iterable[VenueEvent]') -> 'tuple[BrokerState, tuple[LifecycleDecision, ...]]'", + "canonical_status": "function alphalab.broker.order.canonical_status(status: alphalab.core.enums.OrderStatus | alphalab.broker.order.BrokerOrderStatus) -> alphalab.core.enums.OrderStatus", + "check_compatibility": "function alphalab.core.capabilities.check_compatibility(requirements: 'ExecutionRequirements', declaration: 'CapabilityDeclaration') -> 'CompatibilityReport'", + "classify_execution": "function alphalab.broker.reconciliation.classify_execution(state: 'BrokerState', execution: 'BrokerExecution') -> 'ExecutionDecision'", + "classify_venue_event": "function alphalab.broker.lifecycle.classify_venue_event(state: 'BrokerState', event: 'VenueEvent') -> 'LifecycleDecision'", + "executions": "function alphalab.broker.views.executions(state: alphalab.broker.state.BrokerState) -> collections.abc.Sequence[alphalab.broker.execution.BrokerExecution]", + "issue_cancel": "function alphalab.broker.requests.issue_cancel(state: 'BrokerState', ledger: 'RequestLedger', request: 'CancelRequest') -> 'tuple[BrokerState, RequestLedger, RequestDecision]'", + "issue_modify": "function alphalab.broker.requests.issue_modify(state: 'BrokerState', ledger: 'RequestLedger', request: 'ModifyRequest') -> 'tuple[BrokerState, RequestLedger, RequestDecision]'", + "open_orders": "function alphalab.broker.views.open_orders(state: alphalab.broker.state.BrokerState) -> collections.abc.Sequence[alphalab.broker.order.BrokerOrder]", + "order_requirements": "function alphalab.core.capabilities.order_requirements(*, asset_class: 'AssetType', listing_venue: 'str', account_id: 'str', order_type: 'OrderType', time_in_force: 'TimeInForce', side: 'Side', quantity: 'Decimal', position_before: 'Decimal', extended_hours: 'bool', bracket_orders: 'bool', margin: 'bool', features: 'frozenset[Capability]') -> 'ExecutionRequirements'", + "positions": "function alphalab.broker.views.positions(state: alphalab.broker.state.BrokerState) -> collections.abc.Sequence[alphalab.broker.position.BrokerPosition]", + "reconcile": "function alphalab.broker.reconciliation.reconcile(state: 'BrokerState', remote_orders: 'Sequence[BrokerOrder]', remote_positions: 'Sequence[BrokerPosition]' = (), remote_account: 'BrokerAccount | None' = None) -> 'ReconciliationReport'", + "reconcile_snapshot": "function alphalab.broker.reconciliation.reconcile_snapshot(state: 'BrokerState', snapshot: 'VenueSnapshot', *, evaluated_at: 'float', max_age_seconds: 'float') -> 'SnapshotReconciliation'", + "supports": "function alphalab.core.capabilities.supports(declaration: 'CapabilityDeclaration', capability: 'Capability', *, asset_class: 'AssetType | None' = None, listing_venue: 'str | None' = None, account_id: 'str | None' = None) -> 'Support'", + "validate_cancel_request": "function alphalab.broker.validation.validate_cancel_request(state: alphalab.broker.state.BrokerState, broker_order_id: str) -> None", + "validate_execution": "function alphalab.broker.validation.validate_execution(state: alphalab.broker.state.BrokerState, execution_id: str) -> None", + "validate_order_submission": "function alphalab.broker.validation.validate_order_submission(state: alphalab.broker.state.BrokerState, order: alphalab.broker.order.BrokerOrder) -> None", + "validate_replace_request": "function alphalab.broker.validation.validate_replace_request(state: alphalab.broker.state.BrokerState, broker_order_id: str, new_quantity: decimal.Decimal, new_price: decimal.Decimal) -> None" + }, + "alphalab.broker.snapshot": { + "BROKER_SCHEMA_HISTORY": "value alphalab.persistence.upgrade.SchemaHistory", + "BROKER_SNAPSHOT_SCHEMA": "value builtins.int", + "BrokerEventRecord": "class alphalab.broker.snapshot.BrokerEventRecord(event_type: 'str', event: 'BrokerEvent') -> None", + "BrokerSnapshot": "class alphalab.broker.snapshot.BrokerSnapshot(broker_name: 'str', connection_status: 'ConnectionStatus', account: 'BrokerAccount', positions: 'tuple[BrokerPosition, ...]' = (), orders: 'tuple[BrokerOrder, ...]' = (), executions: 'tuple[BrokerExecution, ...]' = (), events: 'tuple[BrokerEventRecord, ...]' = (), metadata: 'Mapping[str, str]' = , last_heartbeat: 'float' = 0.0, order_bindings: 'Mapping[str, str]' = , breaks: 'tuple[ExecutionDecision, ...]' = (), duplicates: 'tuple[ExecutionDecision, ...]' = (), venue_sequences: 'Mapping[str, int]' = , schema_version: 'int' = 2) -> None", + "BrokerSnapshotDecodeError": "class alphalab.broker.snapshot.BrokerSnapshotDecodeError", + "capture": "function alphalab.broker.snapshot.capture(state: 'BrokerState', mapping: 'ExternalOrderMap | None' = None, log: 'ReconciliationLog | None' = None) -> 'BrokerSnapshot'", + "from_primitives": "function alphalab.broker.snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'BrokerSnapshot'", + "restore": "function alphalab.broker.snapshot.restore(snapshot: 'BrokerSnapshot') -> 'tuple[BrokerState, ExternalOrderMap, ReconciliationLog]'" + }, + "alphalab.brokers": { + "BrokerAccount": "class alphalab.broker.account.BrokerAccount(account_id: str, cash: decimal.Decimal, equity: decimal.Decimal, buying_power: decimal.Decimal, margin: decimal.Decimal, available_funds: decimal.Decimal, currency: str, broker_id: str = '', metadata: collections.abc.Mapping[str, str] = ) -> None", + "BrokerConnection": "class alphalab.brokers.connection.BrokerConnection(broker_id: str, broker_name: str, broker_type: alphalab.brokers.connection.BrokerType, connected: bool = False, latency_ms: float = 0.0, last_heartbeat: float = 0.0) -> None", + "BrokerConnectorAdapter": "class alphalab.brokers.adapter.BrokerConnectorAdapter()", + "BrokerConnectorEngine": "class alphalab.brokers.engine.BrokerConnectorEngine()", + "BrokerConnectorError": "class alphalab.brokers.exceptions.BrokerConnectorError", + "BrokerConnectorEvent": "class alphalab.brokers.events.BrokerConnectorEvent(event_id: str, timestamp: float) -> None", + "BrokerConnectorProtocol": "class alphalab.brokers.protocol.BrokerConnectorProtocol(*args, **kwargs)", + "BrokerConnectorState": "class alphalab.brokers.state.BrokerConnectorState(engine_id: str, connections: alphalab.common.persistent_map.PersistentMap[str, alphalab.brokers.connection.BrokerConnection] = , accounts: alphalab.common.persistent_map.PersistentMap[str, alphalab.broker.account.BrokerAccount] = , positions: alphalab.common.persistent_map.PersistentMap[str, alphalab.broker.position.BrokerPosition] = , orders: alphalab.common.persistent_map.PersistentMap[str, alphalab.broker.order.BrokerOrder] = , executions: alphalab.common.persistent_map.PersistentMap[str, alphalab.broker.execution.BrokerExecution] = , statistics: alphalab.brokers.state.BrokerStatistics = , events: alphalab.common.append_log.AppendOnlyLog[alphalab.brokers.events.BrokerConnectorEvent] = , metadata: alphalab.common.persistent_map.PersistentMap[str, str] = ) -> None", + "BrokerConnectorStateError": "class alphalab.brokers.exceptions.BrokerConnectorStateError", + "BrokerConnectorValidationError": "class alphalab.brokers.exceptions.BrokerConnectorValidationError", + "BrokerExecution": "class alphalab.broker.execution.BrokerExecution(execution_id: str, broker_order_id: str, symbol: str, fill_quantity: decimal.Decimal, fill_price: decimal.Decimal, commission: decimal.Decimal, timestamp: float, account_id: str = '', external_id: str = '') -> None", + "BrokerOrder": "class alphalab.broker.order.BrokerOrder(broker_order_id: str, oms_order_id: str, symbol: str, side: alphalab.core.enums.Side, order_type: alphalab.common.order_terms.OrderType, quantity: decimal.Decimal, price: decimal.Decimal, filled_quantity: decimal.Decimal, average_fill_price: decimal.Decimal, status: alphalab.core.enums.OrderStatus | alphalab.broker.order.BrokerOrderStatus, created_at: float, updated_at: float, account_id: str = '', tif: alphalab.common.order_terms.TimeInForce = , stop_price: decimal.Decimal = Decimal('0')) -> None", + "BrokerOrderStatus": "enum alphalab.broker.order.BrokerOrderStatus[PENDING_SUBMIT, SUBMITTED, PENDING_CANCEL]", + "BrokerPosition": "class alphalab.broker.position.BrokerPosition(symbol: str, quantity: decimal.Decimal, average_price: decimal.Decimal, market_value: decimal.Decimal, unrealized_pnl: decimal.Decimal, realized_pnl: decimal.Decimal, account_id: str = '', asset_class: alphalab.core.enums.AssetType = , market_price: decimal.Decimal = Decimal('0')) -> None", + "BrokerRegistered": "class alphalab.brokers.events.BrokerRegistered(event_id: str, timestamp: float, broker_id: str, broker_type: str) -> None", + "BrokerRegistry": "class alphalab.brokers.registry.BrokerRegistry()", + "BrokerStatistics": "class alphalab.brokers.state.BrokerStatistics(total_orders_submitted: int = 0, total_executions_received: int = 0, total_errors: int = 0) -> None", + "BrokerType": "enum alphalab.brokers.connection.BrokerType[PAPER, REST, STREAMING, HYBRID]", + "OrderManager": "class alphalab.brokers.manager.OrderManager()", + "OrderSide": "enum alphalab.core.enums.Side[BUY, SELL]", + "OrderType": "enum alphalab.common.order_terms.OrderType[MARKET, LIMIT, STOP, STOP_LIMIT]", + "RegisteredBrokerConnected": "class alphalab.brokers.events.RegisteredBrokerConnected(event_id: str, timestamp: float, broker_id: str) -> None", + "RegisteredBrokerDisconnected": "class alphalab.brokers.events.RegisteredBrokerDisconnected(event_id: str, timestamp: float, broker_id: str, reason: str) -> None", + "RegisteredBrokerHeartbeat": "class alphalab.brokers.events.RegisteredBrokerHeartbeat(event_id: str, timestamp: float, broker_id: str, latency_ms: float) -> None", + "RoutedExecutionReceived": "class alphalab.brokers.events.RoutedExecutionReceived(event_id: str, timestamp: float, execution_id: str, broker_order_id: str, fill_price: decimal.Decimal, fill_quantity: decimal.Decimal) -> None", + "RoutedOrderCancelled": "class alphalab.brokers.events.RoutedOrderCancelled(event_id: str, timestamp: float, broker_order_id: str, account_id: str) -> None", + "RoutedOrderFilled": "class alphalab.brokers.events.RoutedOrderFilled(event_id: str, timestamp: float, broker_order_id: str, account_id: str, fill_quantity: decimal.Decimal) -> None", + "RoutedOrderSubmitted": "class alphalab.brokers.events.RoutedOrderSubmitted(event_id: str, timestamp: float, broker_order_id: str, account_id: str, symbol: str) -> None", + "TimeInForce": "enum alphalab.common.order_terms.TimeInForce[DAY, GTC, IOC, FOK, GTD, OPG, CLS]", + "active_brokers": "function alphalab.brokers.views.active_brokers(state: alphalab.brokers.state.BrokerConnectorState) -> collections.abc.Sequence[alphalab.brokers.connection.BrokerConnection]", + "engine_statistics": "function alphalab.brokers.views.engine_statistics(state: alphalab.brokers.state.BrokerConnectorState) -> alphalab.brokers.state.BrokerStatistics", + "get_account": "function alphalab.brokers.views.get_account(state: alphalab.brokers.state.BrokerConnectorState, account_id: str) -> alphalab.broker.account.BrokerAccount | None", + "list_executions": "function alphalab.brokers.views.list_executions(state: alphalab.brokers.state.BrokerConnectorState, broker_order_id: str) -> collections.abc.Sequence[alphalab.broker.execution.BrokerExecution]", + "list_positions": "function alphalab.brokers.views.list_positions(state: alphalab.brokers.state.BrokerConnectorState, account_id: str) -> collections.abc.Sequence[alphalab.broker.position.BrokerPosition]", + "open_routed_orders": "function alphalab.brokers.views.open_routed_orders(state: alphalab.brokers.state.BrokerConnectorState) -> collections.abc.Sequence[alphalab.broker.order.BrokerOrder]", + "validate_account": "function alphalab.brokers.validation.validate_account(state: alphalab.brokers.state.BrokerConnectorState, account_id: str, broker_id: str) -> None", + "validate_broker_registration": "function alphalab.brokers.validation.validate_broker_registration(state: alphalab.brokers.state.BrokerConnectorState, connection: alphalab.brokers.connection.BrokerConnection) -> None", + "validate_order_cancellation": "function alphalab.brokers.validation.validate_order_cancellation(state: alphalab.brokers.state.BrokerConnectorState, broker_order_id: str) -> alphalab.broker.order.BrokerOrder", + "validate_routed_execution": "function alphalab.brokers.validation.validate_routed_execution(state: alphalab.brokers.state.BrokerConnectorState, execution_id: str, broker_order_id: str) -> alphalab.broker.order.BrokerOrder", + "validate_routed_submission": "function alphalab.brokers.validation.validate_routed_submission(state: alphalab.brokers.state.BrokerConnectorState, order: alphalab.broker.order.BrokerOrder) -> None" + }, + "alphalab.cloud_research": { + "CloudResearchError": "class alphalab.cloud_research.exceptions.CloudResearchError", + "CloudResearchInputError": "class alphalab.cloud_research.exceptions.CloudResearchInputError", + "CloudResearchState": "class alphalab.cloud_research.cluster.CloudResearchState(distributed: alphalab.distributed.state.DistributedState, results: collections.abc.Mapping[str, Any] = ) -> None", + "collect_sweep": "function alphalab.cloud_research.sweep.collect_sweep(state: alphalab.cloud_research.cluster.CloudResearchState, param_grid: alphalab.research.walk_forward_optimization.ParameterSpace | collections.abc.Mapping[str, collections.abc.Sequence[ParamValue]], job_ids: collections.abc.Sequence[str], metric: str, score: collections.abc.Callable[[Any], float], *, higher_is_better: bool = True) -> alphalab.research.overfitting.SweepResult", + "initialize_cluster": "function alphalab.cloud_research.cluster.initialize_cluster(cluster_id: str, num_workers: int, capacity_per_worker: int, timestamp: float) -> alphalab.cloud_research.cluster.CloudResearchState", + "resolve_task": "function alphalab.cloud_research.task.resolve_task(task_path: str) -> collections.abc.Callable[..., Any]", + "run_cluster_cycle": "function alphalab.cloud_research.cluster.run_cluster_cycle(state: alphalab.cloud_research.cluster.CloudResearchState, executor: concurrent.futures._base.Executor, timestamp: float) -> alphalab.cloud_research.cluster.CloudResearchState", + "run_task": "function alphalab.cloud_research.task.run_task(payload: collections.abc.Mapping[str, Any]) -> dict[str, Any]", + "submit_parameter_sweep": "function alphalab.cloud_research.sweep.submit_parameter_sweep(state: alphalab.cloud_research.cluster.CloudResearchState, job_type: alphalab.distributed.job.JobType, task_path: str, param_grid: alphalab.research.walk_forward_optimization.ParameterSpace | collections.abc.Mapping[str, collections.abc.Sequence[ParamValue]], base_kwargs: collections.abc.Mapping[str, Any], priority: int, timestamp: float) -> tuple[alphalab.cloud_research.cluster.CloudResearchState, tuple[str, ...]]", + "submit_research_job": "function alphalab.cloud_research.cluster.submit_research_job(state: alphalab.cloud_research.cluster.CloudResearchState, job_type: alphalab.distributed.job.JobType, task_path: str, kwargs: collections.abc.Mapping[str, Any], priority: int, timestamp: float) -> tuple[alphalab.cloud_research.cluster.CloudResearchState, str]", + "sweep_space": "function alphalab.cloud_research.sweep.sweep_space(param_grid: alphalab.research.walk_forward_optimization.ParameterSpace | collections.abc.Mapping[str, collections.abc.Sequence[ParamValue]]) -> alphalab.research.walk_forward_optimization.ParameterSpace" + }, + "alphalab.cluster_scheduler": { + "ClusterSchedulerError": "class alphalab.cluster_scheduler.exceptions.ClusterSchedulerError", + "ClusterSchedulerInputError": "class alphalab.cluster_scheduler.exceptions.ClusterSchedulerInputError", + "assign_jobs_with_affinity": "function alphalab.cluster_scheduler.affinity_scheduler.assign_jobs_with_affinity(state: alphalab.distributed.state.DistributedState, timestamp: float) -> alphalab.distributed.state.DistributedState", + "assign_jobs_with_aging": "function alphalab.cluster_scheduler.priority_scheduler.assign_jobs_with_aging(state: alphalab.distributed.state.DistributedState, timestamp: float, aging_rate: float = 0.0) -> alphalab.distributed.state.DistributedState", + "queue_position": "function alphalab.cluster_scheduler.queue_manager.queue_position(state: alphalab.distributed.state.DistributedState, job_id: str) -> int | None" + }, + "alphalab.common": { + "AlphaLabError": "class alphalab.common.exceptions.AlphaLabError", + "AlphaLabRegistryError": "class alphalab.common.exceptions.AlphaLabRegistryError", + "AlphaLabSerializationError": "class alphalab.common.exceptions.AlphaLabSerializationError", + "AlphaLabValidationError": "class alphalab.common.exceptions.AlphaLabValidationError", + "AppendOnlyLog": "class alphalab.common.append_log.AppendOnlyLog(items: 'Iterable[T]' = ()) -> 'None'", + "AvailabilityBasis": "enum alphalab.common.point_in_time.AvailabilityBasis[DECLARED, DERIVED, UNKNOWN]", + "BaseEvent": "class alphalab.common.events.BaseEvent(event_id: str, timestamp: float) -> None", + "ConversionRecord": "class alphalab.common.currency.ConversionRecord(*args, **kwargs)", + "CurrencyConverter": "class alphalab.common.currency.CurrencyConverter(*args, **kwargs)", + "DEFAULT_ENCODING": "value builtins.str", + "DEFAULT_SCHEMA_VERSION": "value builtins.int", + "DeterministicIdSource": "class alphalab.common.ids.DeterministicIdSource(seed: int) -> None", + "Identifier": "newtype alphalab.common.ids.Identifier", + "LinearFit": "class alphalab.common.statistics.LinearFit(slope: 'float', intercept: 'float', r_squared: 'float | None', observations: 'int', residuals: 'tuple[float, ...]') -> None", + "MARKET": "value alphalab.common.order_terms.OrderTerms", + "Metadata": "class alphalab.common.metadata.Metadata(values: collections.abc.Mapping[str, MetadataValue] = ) -> None", + "MetadataMapping": "alias MetadataMapping", + "MetadataValue": "alias MetadataValue", + "OrderTerms": "class alphalab.common.order_terms.OrderTerms(order_type: 'OrderType' = , limit_price: 'Decimal | None' = None, stop_price: 'Decimal | None' = None, time_in_force: 'TimeInForce' = , expire_at: 'float | None' = None) -> None", + "OrderType": "enum alphalab.common.order_terms.OrderType[MARKET, LIMIT, STOP, STOP_LIMIT]", + "PACKAGE_NAME": "value builtins.str", + "ParamValue": "alias ParamValue", + "PersistentMap": "class alphalab.common.persistent_map.PersistentMap(items: 'Mapping[K, V] | Iterable[tuple[K, V]]' = ()) -> 'None'", + "PersistentSet": "class alphalab.common.persistent_map.PersistentSet(members: 'Iterable[T]' = ()) -> 'None'", + "PointInTimeIndex": "class alphalab.common.point_in_time.PointInTimeIndex(visibility: 'VisibilityRule', records: 'tuple[T, ...]', known_instants: 'tuple[float, ...]', unverified: 'tuple[T, ...]') -> None", + "PointInTimeRecord": "class alphalab.common.point_in_time.PointInTimeRecord(*args, **kwargs)", + "PointInTimeStamp": "class alphalab.common.point_in_time.PointInTimeStamp(observed_at: 'float', available_at: 'float | None', basis: 'AvailabilityBasis', effective_at: 'float | None' = None, ingested_at: 'float | None' = None, rule: 'str' = '') -> None", + "RankMethod": "enum alphalab.common.statistics.RankMethod[AVERAGE, MIN, MAX, ORDINAL]", + "RateRecord": "class alphalab.common.currency.RateRecord(*args, **kwargs)", + "Result": "class alphalab.common.results.Result(ok: bool, value: ValueT | None = None, error: str | None = None) -> None", + "StampedRecord": "class alphalab.common.point_in_time.StampedRecord(*args, **kwargs)", + "TieBreak": "enum alphalab.common.statistics.TieBreak[LOW, HIGH, SPREAD]", + "TimeInForce": "enum alphalab.common.order_terms.TimeInForce[DAY, GTC, IOC, FOK, GTD, OPG, CLS]", + "VisibilityRule": "enum alphalab.common.point_in_time.VisibilityRule[PUBLICATION, INGESTION]", + "__version__": "value builtins.str", + "bucket_index": "function alphalab.common.statistics.bucket_index(values: 'Sequence[float]', buckets: 'int', tie_break: 'TieBreak' = ) -> 'tuple[int, ...]'", + "copy_metadata": "function alphalab.common.metadata.copy_metadata(metadata: MetadataMapping) -> dict[str, MetadataValue]", + "dataclass_to_dict": "function alphalab.common.serialization.dataclass_to_dict(instance: object) -> dict[str, Any]", + "ensure_timezone_aware": "function alphalab.common.time.ensure_timezone_aware(value: datetime.datetime, field_name: str = 'timestamp') -> datetime.datetime", + "generic_known_as_of": "function alphalab.common.point_in_time.known_as_of(records: 'tuple[T, ...]', as_of: 'float') -> 'T | None'", + "id_scope": "function alphalab.common.ids.id_scope(seed: int | None) -> contextlib.AbstractContextManager[None]", + "id_source": "function alphalab.common.ids.id_source(seed: int | None) -> collections.abc.Callable[[], str] | None", + "is_uuid": "function alphalab.common.ids.is_uuid(value: str) -> bool", + "linear_regression": "function alphalab.common.statistics.linear_regression(ys: 'Sequence[float]', xs: 'Sequence[float]') -> 'LinearFit'", + "mean": "function alphalab.common.statistics.mean(values: 'Sequence[float]') -> 'float'", + "median": "function alphalab.common.statistics.median(values: 'Sequence[float]') -> 'float'", + "new_id": "function alphalab.common.ids.new_id() -> alphalab.common.ids.Identifier", + "pearson_correlation": "function alphalab.common.statistics.pearson_correlation(xs: 'Sequence[float]', ys: 'Sequence[float]') -> 'float'", + "percentile": "function alphalab.common.statistics.percentile(values: 'Sequence[float]', fraction: 'float') -> 'float'", + "rank_correlation": "function alphalab.common.statistics.rank_correlation(xs: 'Sequence[float]', ys: 'Sequence[float]') -> 'float'", + "ranks": "function alphalab.common.statistics.ranks(values: 'Sequence[float]', method: 'RankMethod' = ) -> 'tuple[float, ...]'", + "require_non_empty_string": "function alphalab.common.validators.require_non_empty_string(value: str, field_name: str, *, message: str | None = None, exception_type: type[Exception] = ) -> str", + "require_non_negative_int": "function alphalab.common.validators.require_non_negative_int(value: int, field_name: str) -> int", + "require_positive_int": "function alphalab.common.validators.require_positive_int(value: int, field_name: str) -> int", + "require_type": "function alphalab.common.validators.require_type(value: object, expected_type: type[T], field_name: str) -> T", + "require_uuid": "function alphalab.common.ids.require_uuid(value: str, field_name: str) -> alphalab.common.ids.Identifier", + "sample_variance": "function alphalab.common.statistics.sample_variance(values: 'Sequence[float]') -> 'float'", + "standard_deviation": "function alphalab.common.statistics.standard_deviation(values: 'Sequence[float]') -> 'float'", + "standardize": "function alphalab.common.statistics.standardize(values: 'Sequence[float]') -> 'tuple[float, ...]'", + "to_utc": "function alphalab.common.time.to_utc(value: datetime.datetime) -> datetime.datetime", + "use_id_source": "function alphalab.common.ids.use_id_source(source: collections.abc.Callable[[], str] | None) -> collections.abc.Iterator[None]", + "utc_now": "function alphalab.common.time.utc_now() -> datetime.datetime", + "winsorize": "function alphalab.common.statistics.winsorize(values: 'Sequence[float]', lower: 'float', upper: 'float') -> 'tuple[float, ...]'" + }, + "alphalab.conventions": { + "CASH_EQUITY": "value alphalab.conventions.economics.InstrumentEconomics", + "Compounding": "enum alphalab.conventions.rates.Compounding[ANNUAL, SEMI_ANNUAL, QUARTERLY, MONTHLY, CONTINUOUS]", + "ContractNotional": "class alphalab.conventions.market.ContractNotional(amount: 'Decimal', currency: 'str', quantity: 'Decimal', price: 'Decimal', multiplier: 'Decimal', underlying_units: 'Decimal') -> None", + "ConventionError": "class alphalab.conventions.exceptions.ConventionError", + "ConventionInputError": "class alphalab.conventions.exceptions.ConventionInputError", + "ConventionViolationError": "class alphalab.conventions.exceptions.ConventionViolationError", + "DayCount": "enum alphalab.conventions.daycount.DayCount[ACT_365_FIXED, ACT_360, THIRTY_360_US]", + "InstrumentEconomics": "class alphalab.conventions.economics.InstrumentEconomics(multiplier: 'Decimal', settlement: 'SettlementModel', lot: 'LotSpecification | None' = None, minimum_notional: 'Decimal | None' = None, allows_negative_prices: 'bool' = False) -> None", + "LotSpecification": "class alphalab.conventions.lot.LotSpecification(lot_size: 'Decimal', minimum_quantity: 'Decimal') -> None", + "MAX_SETTLEMENT_SEARCH_DAYS": "value builtins.int", + "MarketConvention": "class alphalab.conventions.market.MarketConvention(venue: 'str', calendar_id: 'str', quote_currency: 'str', settlement_currency: 'str', multiplier: 'Decimal', tick: 'TickSchedule', lot: 'LotSpecification', settlement: 'SettlementRule') -> None", + "RoundingDirection": "enum alphalab.conventions.tick.RoundingDirection[DOWN, UP, NEAREST]", + "SettlementBasis": "enum alphalab.conventions.settlement.SettlementBasis[TRADE_DATE, TRADING_DAYS, CALENDAR_DAYS]", + "SettlementModel": "enum alphalab.conventions.economics.SettlementModel[CASH_EQUITY, FUTURES_VARIATION, OPTION_PREMIUM, PERPETUAL]", + "SettlementRule": "class alphalab.conventions.settlement.SettlementRule(basis: 'SettlementBasis', offset_days: 'int') -> None", + "TickBand": "class alphalab.conventions.tick.TickBand(upper_bound: 'Decimal | None', tick_size: 'Decimal') -> None", + "TickSchedule": "class alphalab.conventions.tick.TickSchedule(bands: 'tuple[TickBand, ...]') -> None", + "TickValue": "class alphalab.conventions.tick.TickValue(amount: 'Decimal', currency: 'str', tick_size: 'Decimal', multiplier: 'Decimal') -> None", + "TradingDayCalendar": "class alphalab.conventions.settlement.TradingDayCalendar(*args, **kwargs)", + "compound_factor": "function alphalab.conventions.rates.compound_factor(rate: 'float', years: 'float', compounding: 'Compounding') -> 'float'", + "contract_notional": "function alphalab.conventions.market.contract_notional(convention: 'MarketConvention', quantity: 'Decimal', price: 'Decimal') -> 'ContractNotional'", + "day_count_days": "function alphalab.conventions.daycount.day_count_days(start: 'date', end: 'date', basis: 'DayCount') -> 'int'", + "discount_factor": "function alphalab.conventions.rates.discount_factor(rate: 'float', years: 'float', compounding: 'Compounding') -> 'float'", + "economics_from_primitives": "function alphalab.conventions.economics.economics_from_primitives(value: 'Any', where: 'str' = 'economics') -> 'InstrumentEconomics'", + "is_on_tick": "function alphalab.conventions.tick.is_on_tick(price: 'Decimal', tick_size: 'Decimal') -> 'bool'", + "lots_in": "function alphalab.conventions.lot.lots_in(quantity: 'Decimal', specification: 'LotSpecification') -> 'Decimal'", + "round_down_to_lot": "function alphalab.conventions.lot.round_down_to_lot(quantity: 'Decimal', specification: 'LotSpecification') -> 'Decimal'", + "round_to_tick": "function alphalab.conventions.tick.round_to_tick(price: 'Decimal', tick_size: 'Decimal', direction: 'RoundingDirection') -> 'Decimal'", + "settlement_date": "function alphalab.conventions.settlement.settlement_date(rule: 'SettlementRule', trade_date: 'date', calendar: 'TradingDayCalendar | None') -> 'date'", + "tick_value": "function alphalab.conventions.tick.tick_value(tick_size: 'Decimal', multiplier: 'Decimal', currency: 'str', price: 'Decimal | None' = None) -> 'TickValue'", + "year_fraction": "function alphalab.conventions.daycount.year_fraction(start: 'date', end: 'date', basis: 'DayCount') -> 'float'" + }, + "alphalab.core": { + "ACCOUNT_EVENT_KINDS": "value builtins.frozenset", + "ANY_LISTING_VENUE": "value builtins.str", + "AccountCapability": "class alphalab.core.capabilities.AccountCapability(account_id: 'str', asset_classes: 'frozenset[AssetType]', margin: 'Support', short_selling: 'Support') -> None", + "AccountId": "newtype alphalab.core.ids.AccountId", + "AlphaLabCoreError": "class alphalab.core.exceptions.AlphaLabCoreError", + "AssetId": "newtype alphalab.core.ids.AssetId", + "AssetType": "enum alphalab.core.enums.AssetType[EQUITY, FUTURE, OPTION, FOREX, CRYPTO, CASH, BOND, INDEX]", + "CANCEL_REQUESTABLE_STATUSES": "value builtins.frozenset", + "CAPABILITY_DECLARATION_SCHEME": "value builtins.str", + "CAPABILITY_LEVELS": "value builtins.mappingproxy", + "COMPATIBILITY_REPORT_SCHEME": "value builtins.str", + "CONNECTIVITY_EVENT_KINDS": "value builtins.frozenset", + "Capability": "enum alphalab.core.capabilities.Capability[MARKET_ORDERS, LIMIT_ORDERS, STOP_ORDERS, STOP_LIMIT_ORDERS, SHORTING, OPTIONS, FUTURES, FRACTIONAL, MARGIN, STREAMING, CANCEL_REPLACE, BRACKET_ORDERS, EXTENDED_HOURS]", + "CapabilityCheck": "class alphalab.core.capabilities.CapabilityCheck(dimension: 'RequirementDimension', level: 'CapabilityLevel', requirement: 'str', capability: 'Capability | None', support: 'Support', detail: 'str') -> None", + "CapabilityDeclaration": "class alphalab.core.capabilities.CapabilityDeclaration(adapter_id: 'str', supported_features: 'frozenset[Capability]', unsupported_features: 'frozenset[Capability]', markets: 'tuple[MarketCapability, ...]', unsupported_asset_classes: 'frozenset[AssetType]', accounts: 'tuple[AccountCapability, ...]') -> None", + "CapabilityLevel": "enum alphalab.core.capabilities.CapabilityLevel[VENUE, MARKET, ACCOUNT]", + "Compatibility": "enum alphalab.core.capabilities.Compatibility[COMPATIBLE, INCOMPATIBLE, UNDETERMINED]", + "CompatibilityReport": "class alphalab.core.capabilities.CompatibilityReport(requirements: 'ExecutionRequirements', adapter_id: 'str', declaration_id: 'str', checks: 'tuple[CapabilityCheck, ...]') -> None", + "DomainValidationError": "class alphalab.core.exceptions.DomainValidationError", + "EXECUTION_REQUIREMENTS_SCHEME": "value builtins.str", + "EventClassification": "enum alphalab.core.lifecycle.EventClassification[TRANSITION, DUPLICATE, STALE, CONFLICT]", + "EventId": "newtype alphalab.core.ids.EventId", + "EventType": "enum alphalab.core.enums.EventType[SIGNAL, ORDER, FILL, TRADE, POSITION, PORTFOLIO]", + "ExecutionEventKind": "enum alphalab.core.lifecycle.ExecutionEventKind[ORDER_ACCEPTED, ORDER_REJECTED, ORDER_PARTIALLY_FILLED, ORDER_FILLED, ORDER_CANCELLED, ORDER_EXPIRED, ORDER_REPLACED, ORDER_CANCEL_REJECTED, POSITION_CHANGED, BALANCE_CHANGED, BROKER_DISCONNECTED, BROKER_CONNECTED]", + "ExecutionRequirements": "class alphalab.core.capabilities.ExecutionRequirements(asset_class: 'AssetType', listing_venue: 'str', account_id: 'str', order_types: 'frozenset[OrderType]', time_in_force: 'frozenset[TimeInForce]', short_selling: 'bool', fractional_quantities: 'bool', extended_hours: 'bool', bracket_orders: 'bool', margin: 'bool', features: 'frozenset[Capability]') -> None", + "FILL_EVENT_KINDS": "value builtins.frozenset", + "Fill": "class alphalab.core.fill.Fill(fill_id: alphalab.core.ids.FillId, order_id: alphalab.core.ids.OrderId, asset_id: alphalab.core.ids.AssetId, side: alphalab.core.enums.Side, quantity: decimal.Decimal, price: decimal.Decimal, filled_at: float, commission: decimal.Decimal = Decimal('0')) -> None", + "FillId": "newtype alphalab.core.ids.FillId", + "MARKET": "value alphalab.common.order_terms.OrderTerms", + "MarketCapability": "class alphalab.core.capabilities.MarketCapability(asset_class: 'AssetType', listing_venue: 'str', order_types: 'frozenset[OrderType]', time_in_force: 'frozenset[TimeInForce]', short_selling: 'Support', fractional_quantities: 'Support', extended_hours: 'Support', bracket_orders: 'Support') -> None", + "ORDER_EVENT_KINDS": "value builtins.frozenset", + "ORDER_TRANSITIONS": "value builtins.mappingproxy", + "ORDER_TYPE_CAPABILITIES": "value builtins.mappingproxy", + "OrderId": "newtype alphalab.core.ids.OrderId", + "OrderRequest": "class alphalab.core.order_request.OrderRequest(order_id: str, strategy_id: str, asset_id: str, side: alphalab.core.enums.Side, quantity: decimal.Decimal, price: decimal.Decimal, timestamp: float = 0.0, contributions: tuple[alphalab.core.contribution.StrategyContribution, ...] = , terms: alphalab.common.order_terms.OrderTerms = OrderTerms(order_type=, limit_price=None, stop_price=None, time_in_force=, expire_at=None)) -> None", + "OrderStatus": "enum alphalab.core.enums.OrderStatus[NEW, PENDING, ACCEPTED, PARTIALLY_FILLED, FILLED, CANCEL_PENDING, CANCELLED, REJECTED, EXPIRED]", + "OrderTerms": "class alphalab.common.order_terms.OrderTerms(order_type: 'OrderType' = , limit_price: 'Decimal | None' = None, stop_price: 'Decimal | None' = None, time_in_force: 'TimeInForce' = , expire_at: 'float | None' = None) -> None", + "OrderType": "enum alphalab.common.order_terms.OrderType[MARKET, LIMIT, STOP, STOP_LIMIT]", + "PortfolioId": "newtype alphalab.core.ids.PortfolioId", + "PositionId": "newtype alphalab.core.ids.PositionId", + "RequirementDimension": "enum alphalab.core.capabilities.RequirementDimension[MARKET, ORDER_TYPE, TIME_IN_FORCE, EXECUTION_FEATURE, ACCOUNT, RISK]", + "STATUS_EVENT_KINDS": "value builtins.frozenset", + "Side": "enum alphalab.core.enums.Side[BUY, SELL]", + "SignalId": "newtype alphalab.core.ids.SignalId", + "StrategyContribution": "class alphalab.core.contribution.StrategyContribution(strategy_id: str, quantity: decimal.Decimal) -> None", + "StrategyId": "newtype alphalab.core.ids.StrategyId", + "Support": "enum alphalab.core.capabilities.Support[SUPPORTED, UNSUPPORTED, UNDECLARED]", + "TERMINAL_ORDER_STATUSES": "value builtins.frozenset", + "TimeInForce": "enum alphalab.common.order_terms.TimeInForce[DAY, GTC, IOC, FOK, GTD, OPG, CLS]", + "Trade": "class alphalab.core.trade.Trade(trade_id: alphalab.core.ids.TradeId, asset_id: alphalab.core.ids.AssetId, side: alphalab.core.enums.Side, quantity: decimal.Decimal, average_price: decimal.Decimal, fill_ids: tuple[alphalab.core.ids.FillId, ...], executed_at: float, order_id: alphalab.core.ids.OrderId | None = None) -> None", + "TradeId": "newtype alphalab.core.ids.TradeId", + "VENUE_FEATURES": "value builtins.frozenset", + "WORKING_ORDER_STATUSES": "value builtins.frozenset", + "check_compatibility": "function alphalab.core.capabilities.check_compatibility(requirements: 'ExecutionRequirements', declaration: 'CapabilityDeclaration') -> 'CompatibilityReport'", + "classify_order_event": "function alphalab.core.lifecycle.classify_order_event(status: 'OrderStatus', event: 'ExecutionEventKind', filled_quantity: 'Decimal') -> 'tuple[EventClassification, OrderStatus]'", + "contributions_from": "function alphalab.core.contribution.contributions_from(sized: collections.abc.Iterable[tuple[str, decimal.Decimal]]) -> tuple[alphalab.core.contribution.StrategyContribution, ...]", + "is_terminal_status": "function alphalab.core.lifecycle.is_terminal_status(status: 'OrderStatus') -> 'bool'", + "new_account_id": "function alphalab.core.ids.new_account_id() -> alphalab.core.ids.AccountId", + "new_asset_id": "function alphalab.core.ids.new_asset_id() -> alphalab.core.ids.AssetId", + "new_event_id": "function alphalab.core.ids.new_event_id() -> alphalab.core.ids.EventId", + "new_fill_id": "function alphalab.core.ids.new_fill_id() -> alphalab.core.ids.FillId", + "new_order_id": "function alphalab.core.ids.new_order_id() -> alphalab.core.ids.OrderId", + "new_portfolio_id": "function alphalab.core.ids.new_portfolio_id() -> alphalab.core.ids.PortfolioId", + "new_position_id": "function alphalab.core.ids.new_position_id() -> alphalab.core.ids.PositionId", + "new_signal_id": "function alphalab.core.ids.new_signal_id() -> alphalab.core.ids.SignalId", + "new_strategy_id": "function alphalab.core.ids.new_strategy_id() -> alphalab.core.ids.StrategyId", + "new_trade_id": "function alphalab.core.ids.new_trade_id() -> alphalab.core.ids.TradeId", + "new_uuid": "function alphalab.core.ids.new_uuid() -> str", + "next_order_status": "function alphalab.core.lifecycle.next_order_status(status: 'OrderStatus', event: 'ExecutionEventKind', filled_quantity: 'Decimal') -> 'OrderStatus | None'", + "order_requirements": "function alphalab.core.capabilities.order_requirements(*, asset_class: 'AssetType', listing_venue: 'str', account_id: 'str', order_type: 'OrderType', time_in_force: 'TimeInForce', side: 'Side', quantity: 'Decimal', position_before: 'Decimal', extended_hours: 'bool', bracket_orders: 'bool', margin: 'bool', features: 'frozenset[Capability]') -> 'ExecutionRequirements'", + "reachable_statuses": "function alphalab.core.lifecycle.reachable_statuses(status: 'OrderStatus') -> 'frozenset[OrderStatus]'", + "split_by_contribution": "function alphalab.core.contribution.split_by_contribution(amount: decimal.Decimal, contributions: collections.abc.Sequence[alphalab.core.contribution.StrategyContribution], quantum: decimal.Decimal) -> tuple[tuple[str, decimal.Decimal], ...]", + "supports": "function alphalab.core.capabilities.supports(declaration: 'CapabilityDeclaration', capability: 'Capability', *, asset_class: 'AssetType | None' = None, listing_venue: 'str | None' = None, account_id: 'str | None' = None) -> 'Support'", + "validate_uuid_id": "function alphalab.core.ids.validate_uuid_id(value: str, field_name: str) -> None", + "working_status_for": "function alphalab.core.lifecycle.working_status_for(filled_quantity: 'Decimal') -> 'OrderStatus'" + }, + "alphalab.crypto": { + "BASIS_POINT": "value decimal.Decimal", + "CoverageReport": "class alphalab.crypto.availability.CoverageReport(start: 'float', end: 'float', expected_interval_seconds: 'float', expected: 'int', observed: 'int', gaps: 'tuple[ObservationGap, ...]', coverage: 'float | None') -> None", + "CryptoComputationError": "class alphalab.crypto.exceptions.CryptoComputationError", + "CryptoError": "class alphalab.crypto.exceptions.CryptoError", + "CryptoInputError": "class alphalab.crypto.exceptions.CryptoInputError", + "CryptoInstrument": "class alphalab.crypto.instrument.CryptoInstrument(base_asset: str, quote_asset: str, instrument_type: alphalab.crypto.enums.InstrumentType, exchange: str, contract_size: decimal.Decimal, expiry: float | None = None) -> None", + "FeeSchedule": "class alphalab.crypto.venue.FeeSchedule(maker_bps: 'Decimal', taker_bps: 'Decimal') -> None", + "FundingAccrual": "class alphalab.crypto.funding.FundingAccrual(timestamp: float, rate: decimal.Decimal, mark_price: decimal.Decimal, notional: decimal.Decimal, payment: decimal.Decimal) -> None", + "FundingRate": "class alphalab.crypto.funding.FundingRate(instrument_symbol: str, rate: decimal.Decimal, timestamp: float, interval_hours: int) -> None", + "FundingRateHistory": "class alphalab.crypto.funding.FundingRateHistory(instrument_symbol: str, rates: tuple[alphalab.crypto.funding.FundingRate, ...]) -> None", + "FundingSummary": "class alphalab.crypto.funding.FundingSummary(instrument_symbol: str, accruals: tuple[alphalab.crypto.funding.FundingAccrual, ...], total: decimal.Decimal, intervals: int) -> None", + "InstrumentType": "enum alphalab.crypto.enums.InstrumentType[SPOT, FUTURE, PERPETUAL]", + "LiquidityRole": "enum alphalab.crypto.venue.LiquidityRole[MAKER, TAKER]", + "MaintenanceBasis": "enum alphalab.crypto.perpetual.MaintenanceBasis[ENTRY_NOTIONAL, MARK_NOTIONAL]", + "ObservationGap": "class alphalab.crypto.availability.ObservationGap(after: 'float', before: 'float', seconds: 'float', missing: 'int') -> None", + "PriceSource": "enum alphalab.crypto.venue.PriceSource[INDEX, MARK, LAST_TRADE]", + "VenueDispersion": "class alphalab.crypto.venue.VenueDispersion(prices: 'Mapping[str, Decimal]', lowest_venue: 'str', highest_venue: 'str', lowest: 'Decimal', highest: 'Decimal', spread: 'Decimal', relative_spread: 'Decimal') -> None", + "VenueSpecification": "class alphalab.crypto.venue.VenueSpecification(venue: 'str', funding_interval_hours: 'int', fees: 'FeeSchedule', price_source: 'PriceSource', minimum_notional: 'Decimal', settlement_asset: 'str') -> None", + "accrued_funding": "function alphalab.crypto.funding.accrued_funding(history: alphalab.crypto.funding.FundingRateHistory, position_quantity: decimal.Decimal, marks: collections.abc.Mapping[float, decimal.Decimal], contract_size: decimal.Decimal) -> alphalab.crypto.funding.FundingSummary", + "annualized_funding_rate": "function alphalab.crypto.funding.annualized_funding_rate(history: alphalab.crypto.funding.FundingRateHistory) -> decimal.Decimal", + "average_funding_rate": "function alphalab.crypto.funding.average_funding_rate(history: alphalab.crypto.funding.FundingRateHistory) -> decimal.Decimal", + "compute_funding_payment": "function alphalab.crypto.funding.compute_funding_payment(position_quantity: decimal.Decimal, mark_price: decimal.Decimal, funding_rate: decimal.Decimal, contract_size: decimal.Decimal) -> decimal.Decimal", + "compute_liquidation_price": "function alphalab.crypto.perpetual.compute_liquidation_price(entry_price: decimal.Decimal, side: alphalab.portfolio.types.PositionSide, leverage: decimal.Decimal, maintenance_margin_rate: decimal.Decimal, *, basis: alphalab.crypto.perpetual.MaintenanceBasis, quantity: decimal.Decimal, fees: decimal.Decimal, funding: decimal.Decimal) -> decimal.Decimal | None", + "coverage": "function alphalab.crypto.availability.coverage(timestamps: 'Sequence[float]', start: 'float', end: 'float', expected_interval_seconds: 'float') -> 'CoverageReport'", + "cross_venue_dispersion": "function alphalab.crypto.venue.cross_venue_dispersion(prices: 'Mapping[str, Decimal]') -> 'VenueDispersion'", + "crypto_symbol": "function alphalab.crypto.instrument.crypto_symbol(instrument: alphalab.crypto.instrument.CryptoInstrument) -> str", + "funding_instants": "function alphalab.crypto.funding.funding_instants(start: float, end: float, interval_hours: int, anchor: float) -> tuple[float, ...]", + "mark_to_market": "function alphalab.crypto.perpetual.mark_to_market(position: alphalab.portfolio.position.Position, mark_price: decimal.Decimal, timestamp: float) -> tuple[alphalab.portfolio.position.Position, decimal.Decimal]", + "observation_gaps": "function alphalab.crypto.availability.observation_gaps(timestamps: 'Sequence[float]', expected_interval_seconds: 'float') -> 'tuple[ObservationGap, ...]'", + "open_crypto_position": "function alphalab.crypto.instrument.open_crypto_position(instrument: alphalab.crypto.instrument.CryptoInstrument, quantity: decimal.Decimal, price: decimal.Decimal, timestamp: float, *, minor_units: int | None = None) -> alphalab.portfolio.position.Position", + "to_canonical_symbol": "function alphalab.crypto.symbol_normalization.to_canonical_symbol(base_asset: str, quote_asset: str) -> str", + "trading_fee": "function alphalab.crypto.venue.trading_fee(specification: 'VenueSpecification', notional: 'Decimal', role: 'LiquidityRole') -> 'Decimal'" + }, + "alphalab.data": { + "AdjustmentOutcome": "class alphalab.data.corporate_actions.AdjustmentOutcome(bars: 'tuple[Bar, ...]', basis: 'PriceBasis', adjustments: 'tuple[AdjustmentRecord, ...]') -> None", + "AdjustmentRecord": "class alphalab.data.corporate_actions.AdjustmentRecord(action: 'str', symbol: 'str', effective_timestamp: 'float', factor: 'float', bars_affected: 'int', detail: 'str') -> None", + "AlternativeDataRecord": "class alphalab.data.feed.AlternativeDataRecord(symbol: str, timestamp: float, source: str, sentiment_score: float) -> None", + "AmbiguousField": "class alphalab.data.schema.AmbiguousField(role: 'FieldRole', columns: 'tuple[str, ...]', reason: 'str') -> None", + "Bar": "class alphalab.data.feed.Bar(symbol: str, timestamp: float, open: float, high: float, low: float, close: float, volume: float) -> None", + "BarStamp": "enum alphalab.data.time.BarStamp[INTERVAL_START, INTERVAL_END]", + "COLUMN_ALIASES": "value builtins.dict", + "CONTENT_DIGEST_SCHEME": "value builtins.str", + "CONTINUOUS_SESSION": "value alphalab.data.calendar.SessionWindow", + "CanonicalRecord": "class alphalab.data.feed.CanonicalRecord(symbol: str, timestamp: float) -> None", + "CatalogRecord": "class alphalab.data.catalog.CatalogRecord(metadata: alphalab.data.metadata.DatasetMetadata, quality: alphalab.data.quality.QualityReport, record_count: int) -> None", + "CleaningOutcome": "class alphalab.data.cleaning.CleaningOutcome(records: 'tuple[CanonicalRecord, ...]', transformations: 'tuple[TransformationRecord, ...]') -> None", + "CleaningPolicy": "class alphalab.data.cleaning.CleaningPolicy(duplicates: 'DuplicatePolicy', ordering: 'OrderingPolicy', invalid_records: 'InvalidRecordPolicy', missing_values: 'MissingValuePolicy') -> None", + "CommoditySpec": "class alphalab.data.assets.CommoditySpec(symbol: 'str', currency: 'str', unit: 'str', delivery_location: 'str') -> None", + "CorporateAction": "class alphalab.data.feed.CorporateAction(symbol: str, timestamp: float, action_type: str, details: str) -> None", + "CryptoSpec": "class alphalab.data.assets.CryptoSpec(symbol: 'str', venue: 'str', base_asset: 'str', quote_asset: 'str') -> None", + "CsvDialect": "class alphalab.data.csv_source.CsvDialect(delimiter: 'str', quote_char: 'str' = '\"', has_header: 'bool' = True) -> None", + "DATASET_KEY_SCHEME": "value builtins.str", + "DELIMITER_CANDIDATES": "value builtins.tuple", + "DataAdapter": "class alphalab.data.adapter.DataAdapter()", + "DataAssetClass": "enum alphalab.data.symbols.DataAssetClass[EQUITY, ETF, FUTURE, OPTION, FOREX, CRYPTO, FUNDAMENTAL, ECONOMIC, ALTERNATIVE, INDEX, RATE, COMMODITY]", + "DataEvent": "class alphalab.data.events.DataEvent(event_id: str, timestamp: float) -> None", + "DataExtractorProtocol": "class alphalab.data.protocol.DataExtractorProtocol(*args, **kwargs)", + "DataManager": "class alphalab.data.manager.DataManager()", + "DataQualityError": "class alphalab.data.exceptions.DataQualityError", + "DataQualityReport": "class alphalab.data.quality.DataQualityReport(dataset_id: 'str', row_count: 'int', valid_rows: 'int', rejected_rows: 'tuple[RowRejection, ...]' = (), duplicate_count: 'int' = 0, missing_count: 'int' = 0, invalid_count: 'int' = 0, out_of_order_count: 'int' = 0, warnings: 'tuple[ValidationFinding, ...]' = (), errors: 'tuple[ValidationFinding, ...]' = ) -> None", + "DataValidationError": "class alphalab.data.exceptions.DataValidationError", + "Dataset": "class alphalab.data.dataset.Dataset(metadata: 'DatasetMetadata', schema: 'DatasetSchema', quality: 'QualityReport', records: 'tuple[CanonicalRecord, ...]' = , provenance: 'DatasetProvenance | None' = None, quality_detail: 'DataQualityReport | None' = None) -> None", + "DatasetCataloged": "class alphalab.data.events.DatasetCataloged(event_id: str, timestamp: float, dataset_id: str, asset_class: str) -> None", + "DatasetCleaned": "class alphalab.data.events.DatasetCleaned(event_id: str, timestamp: float, dataset_id: str, records_removed: int, derived_dataset_id: str = '') -> None", + "DatasetIngested": "class alphalab.data.events.DatasetIngested(event_id: str, timestamp: float, dataset_id: str, record_count: int) -> None", + "DatasetMetadata": "class alphalab.data.metadata.DatasetMetadata(dataset_id: str, source_name: str, asset_class: alphalab.data.symbols.DataAssetClass, frequency: alphalab.data.time.TimeFrequency, start_timestamp: float, end_timestamp: float, timezone: str = 'UTC', metadata: collections.abc.Mapping[str, str] = ) -> None", + "DatasetProvenance": "class alphalab.data.provenance.DatasetProvenance(source: 'RawSource', schema: 'DatasetSchema', timezone_name: 'str', frequency: 'TimeFrequency', price_basis: 'PriceBasis', cleaning_policy: 'CleaningPolicy', engine_version: 'str', dataset_version: 'str', calendar_id: 'str | None' = None, transformations: 'tuple[TransformationRecord, ...]' = (), adjustments: 'tuple[AdjustmentRecord, ...]' = (), instrument: 'InstrumentSpec | None' = None) -> None", + "DatasetRegistry": "class alphalab.data.registry.DatasetRegistry()", + "DatasetResampled": "class alphalab.data.events.DatasetResampled(event_id: str, timestamp: float, dataset_id: str, derived_dataset_id: str, interval_seconds: float, record_count: int) -> None", + "DatasetSchema": "class alphalab.data.schema.DatasetSchema(fields: 'tuple[str, ...]', data_type: 'str', bindings: 'Mapping[str, str]' = , timestamp_format: 'str | None' = None, timezone: 'str | None' = None, source_columns: 'tuple[str, ...]' = (), aggressor_codes: 'Mapping[str, str]' = ) -> None", + "DateOnlyPolicy": "enum alphalab.data.time.DateOnlyPolicy[START_OF_DAY, END_OF_DAY]", + "Delisting": "class alphalab.data.corporate_actions.Delisting(symbol: 'str', effective_timestamp: 'float', reason: 'str') -> None", + "Dividend": "class alphalab.data.feed.Dividend(symbol: str, timestamp: float, amount: float, currency: str) -> None", + "DuplicatePolicy": "enum alphalab.data.cleaning.DuplicatePolicy[REFUSE, KEEP_FIRST, KEEP_LAST]", + "EconomicEvent": "class alphalab.data.feed.EconomicEvent(symbol: str, timestamp: float, event_name: str, actual: float, forecast: float, previous: float) -> None", + "EquitySpec": "class alphalab.data.assets.EquitySpec(symbol: 'str', currency: 'str', exchange: 'str', is_fund: 'bool' = False) -> None", + "FieldBinding": "class alphalab.data.schema.FieldBinding(role: 'FieldRole', column: 'str', rationale: 'str') -> None", + "FieldRole": "enum alphalab.data.schema.FieldRole[TIMESTAMP, SYMBOL, OPEN, HIGH, LOW, CLOSE, VOLUME, TRADE_COUNT, BID, ASK, BID_SIZE, ASK_SIZE, PRICE, SIZE, TRADE_ID, AGGRESSOR]", + "FindingKind": "enum alphalab.data.validation.FindingKind[MISSING_VALUE, NON_NUMERIC, NON_FINITE, UNPARSEABLE_TIMESTAMP, MALFORMED_ROW, EMPTY_SYMBOL, DUPLICATE_TIMESTAMP, OUT_OF_ORDER, INVALID_OHLC, NON_POSITIVE_PRICE, NEGATIVE_VOLUME, CROSSED_QUOTE, INCONSISTENT_FREQUENCY, INCONSISTENT_RECORD, NON_POSITIVE_SIZE, DUPLICATE_TRADE_ID, INDISTINGUISHABLE_PRINTS, UNKNOWN_CODE]", + "FundamentalRecord": "class alphalab.data.feed.FundamentalRecord(symbol: str, timestamp: float, metric_name: str, metric_value: float) -> None", + "FutureSpec": "class alphalab.data.assets.FutureSpec(symbol: 'str', currency: 'str', exchange: 'str', root: 'str', expiry: 'float', multiplier: 'float', tick_size: 'float', contract_month: 'float | None' = None) -> None", + "FxSpec": "class alphalab.data.assets.FxSpec(symbol: 'str', base_currency: 'str', quote_currency: 'str') -> None", + "IndexSpec": "class alphalab.data.assets.IndexSpec(symbol: 'str', currency: 'str', publisher: 'str') -> None", + "IngestionRequest": "class alphalab.data.ingestion.IngestionRequest(name: 'str', source: 'RawSource', frequency: 'TimeFrequency', asset_class: 'DataAssetClass', cleaning_policy: 'CleaningPolicy', price_basis: 'PriceBasis', timezone_name: 'str | None' = None, date_policy: 'DateOnlyPolicy | None' = None, bar_stamp: 'BarStamp | None' = None, symbol: 'str | None' = None, calendar: 'MarketCalendar | None' = None, instrument: 'InstrumentSpec | None' = None, declared_record_type: 'RecordType | None' = None, splits: 'tuple[Split, ...]' = (), dividends: 'tuple[Dividend, ...]' = (), target_basis: 'PriceBasis | None' = None, trade_columns: 'TradeColumns | None' = None) -> None", + "IngestionResult": "class alphalab.data.ingestion.IngestionResult(dataset: 'Dataset', detection: 'SchemaDetection', quality: 'DataQualityReport', transformations: 'tuple[TransformationRecord, ...]', adjustments: 'tuple[AdjustmentRecord, ...]', assumptions: 'tuple[str, ...]') -> None", + "InstrumentLifecycleEvent": "alias InstrumentLifecycleEvent", + "InstrumentSpec": "alias InstrumentSpec", + "InvalidDataStateError": "class alphalab.data.exceptions.InvalidDataStateError", + "InvalidRecordPolicy": "enum alphalab.data.cleaning.InvalidRecordPolicy[REFUSE, DROP, KEEP]", + "MalformedRow": "class alphalab.data.csv_source.MalformedRow(line_number: 'int', values: 'tuple[str, ...]', reason: 'str') -> None", + "MarketCalendar": "class alphalab.data.calendar.MarketCalendar(calendar_id: 'str', timezone_name: 'str', weekly_sessions: 'Mapping[int, tuple[SessionWindow, ...]]', holidays: 'frozenset[date]' = frozenset(), special_sessions: 'Mapping[date, tuple[SessionWindow, ...]]' = ) -> None", + "Merger": "class alphalab.data.corporate_actions.Merger(target_symbol: 'str', acquirer_symbol: 'str', share_ratio: 'float', cash_per_share: 'float', effective_timestamp: 'float') -> None", + "MissingValuePolicy": "enum alphalab.data.cleaning.MissingValuePolicy[REFUSE, DROP_ROW]", + "OptionSpec": "class alphalab.data.assets.OptionSpec(symbol: 'str', currency: 'str', exchange: 'str', underlying_symbol: 'str', strike: 'float', expiry: 'float', option_type: 'OptionType', multiplier: 'float', style: 'ExerciseStyle') -> None", + "OrderBook": "class alphalab.data.feed.OrderBook(symbol: str, timestamp: float, bids: tuple[alphalab.data.feed.OrderBookLevel, ...] = , asks: tuple[alphalab.data.feed.OrderBookLevel, ...] = ) -> None", + "OrderBookLevel": "class alphalab.data.feed.OrderBookLevel(price: float, size: float) -> None", + "OrderingPolicy": "enum alphalab.data.cleaning.OrderingPolicy[REFUSE, SORT]", + "PriceBasis": "enum alphalab.data.corporate_actions.PriceBasis[RAW, SPLIT_ADJUSTED, TOTAL_RETURN]", + "QualityReport": "class alphalab.data.quality.QualityReport(dataset_id: 'str', completeness: 'float', consistency: 'float', missing_count: 'int', duplicate_count: 'int', invalid_count: 'int', out_of_order_count: 'int', quality_score: 'float') -> None", + "QualityReportGenerated": "class alphalab.data.events.QualityReportGenerated(event_id: str, timestamp: float, dataset_id: str, quality_score: float) -> None", + "Quote": "class alphalab.data.feed.Quote(symbol: str, timestamp: float, bid: float, ask: float, bid_size: float, ask_size: float) -> None", + "REFUSE_EVERYTHING": "value alphalab.data.cleaning.CleaningPolicy", + "RateSpec": "class alphalab.data.assets.RateSpec(symbol: 'str', currency: 'str', tenor_days: 'int', day_count: 'str', quoted_in_percent: 'bool') -> None", + "RawRow": "class alphalab.data.csv_source.RawRow(line_number: 'int', values: 'tuple[str, ...]') -> None", + "RawSource": "class alphalab.data.source.RawSource(kind: 'SourceKind', location: 'str', retrieved_at: 'float', content_hash: 'str', byte_count: 'int', media_type: 'str', encoding: 'str | None' = None, attributes: 'MetadataMapping' = ) -> None", + "RawTable": "class alphalab.data.csv_source.RawTable(columns: 'tuple[str, ...]', rows: 'tuple[RawRow, ...]', malformed: 'tuple[MalformedRow, ...]', dialect: 'CsvDialect') -> None", + "RecordType": "enum alphalab.data.schema.RecordType[BAR, QUOTE, TRADE]", + "RowRejection": "class alphalab.data.validation.RowRejection(line_number: 'int', values: 'tuple[str, ...]', findings: 'tuple[ValidationFinding, ...]') -> None", + "SchemaDetection": "class alphalab.data.schema.SchemaDetection(bindings: 'tuple[FieldBinding, ...]', ambiguous: 'tuple[AmbiguousField, ...]', unresolved: 'tuple[FieldRole, ...]', unmapped_columns: 'tuple[str, ...]', record_type: 'RecordType | None', record_type_rationale: 'str', timestamp_format: 'TimestampFormat | None', timestamp_rationale: 'str', assumptions: 'tuple[str, ...]' = (), aggressor_codes: 'Mapping[str, TradeAggressor]' = ) -> None", + "SessionWindow": "class alphalab.data.calendar.SessionWindow(opens: 'time', closes: 'time') -> None", + "Severity": "enum alphalab.data.validation.Severity[WARNING, ERROR]", + "SourceKind": "enum alphalab.data.source.SourceKind[LOCAL_FILE, UPLOADED_FILE, HTTP_RESPONSE, OBJECT_STORAGE, BROKER_EXPORT, VENDOR_FILE, IN_MEMORY]", + "Split": "class alphalab.data.feed.Split(symbol: str, timestamp: float, ratio: float) -> None", + "SymbolChange": "class alphalab.data.corporate_actions.SymbolChange(old_symbol: 'str', new_symbol: 'str', effective_timestamp: 'float') -> None", + "TimeFrequency": "enum alphalab.data.time.TimeFrequency[TICK, SECOND, MINUTE, HOURLY, DAILY, WEEKLY, MONTHLY, CUSTOM]", + "TimestampFormat": "enum alphalab.data.time.TimestampFormat[EPOCH_SECONDS, EPOCH_MILLISECONDS, ISO_8601_AWARE, ISO_8601_NAIVE, DATE_ONLY]", + "Trade": "class alphalab.data.feed.Trade(symbol: str, timestamp: float, price: float, size: float, trade_id: str = '', aggressor: alphalab.data.feed.TradeAggressor | None = None) -> None", + "TradeAggressor": "enum alphalab.data.feed.TradeAggressor[BUYER, SELLER]", + "TradeColumns": "class alphalab.data.schema.TradeColumns(timestamp: 'str', price: 'str', size: 'str', symbol: 'str | None' = None, trade_id: 'str | None' = None, aggressor: 'str | None' = None, aggressor_codes: 'Mapping[str, TradeAggressor]' = ) -> None", + "TransformationRecord": "class alphalab.data.cleaning.TransformationRecord(operation: 'str', rows_affected: 'int', reason: 'str') -> None", + "UniversalDataEngine": "class alphalab.data.engine.UniversalDataEngine()", + "UniversalDataError": "class alphalab.data.exceptions.UniversalDataError", + "UniversalDataState": "class alphalab.data.state.UniversalDataState(engine_id: str, datasets: alphalab.common.persistent_map.PersistentMap[str, alphalab.data.dataset.Dataset] = , catalog: alphalab.common.persistent_map.PersistentMap[str, alphalab.data.catalog.CatalogRecord] = , quality_reports: alphalab.common.persistent_map.PersistentMap[str, alphalab.data.quality.QualityReport] = , schemas: alphalab.common.persistent_map.PersistentMap[str, alphalab.data.schema.DatasetSchema] = , metadata: alphalab.common.persistent_map.PersistentMap[str, alphalab.data.metadata.DatasetMetadata] = , lineage: alphalab.common.persistent_map.PersistentMap[str, str] = , events: alphalab.common.append_log.AppendOnlyLog[alphalab.data.events.DataEvent] = ) -> None", + "ValidationFinding": "class alphalab.data.validation.ValidationFinding(kind: 'FindingKind', severity: 'Severity', line_number: 'int | None', column: 'str | None', detail: 'str') -> None", + "apply_adjustments": "function alphalab.data.corporate_actions.apply_adjustments(bars: 'Sequence[Bar]', splits: 'Sequence[Split]', dividends: 'Sequence[Dividend]', basis: 'PriceBasis') -> 'AdjustmentOutcome'", + "asset_class_of": "function alphalab.data.assets.asset_class_of(spec: 'InstrumentSpec') -> 'DataAssetClass'", + "canonical_dataset_key": "function alphalab.data.provenance.canonical_dataset_key(name: 'str', content_hash: 'str', schema: 'DatasetSchema', timezone_name: 'str', calendar_id: 'str | None', frequency: 'TimeFrequency', price_basis: 'PriceBasis', cleaning_policy: 'CleaningPolicy', transformations: 'Sequence[TransformationRecord]', adjustments: 'Sequence[AdjustmentRecord]') -> 'str'", + "canonical_field": "function alphalab.data.formats.canonical_field(column: str) -> str", + "catalog_summary": "function alphalab.data.views.catalog_summary(state: alphalab.data.state.UniversalDataState) -> collections.abc.Sequence[alphalab.data.catalog.CatalogRecord]", + "clean_records": "function alphalab.data.cleaning.clean_records(records: 'Sequence[CanonicalRecord]', policy: 'CleaningPolicy', findings: 'Sequence[ValidationFinding]') -> 'CleaningOutcome'", + "coerce_row": "function alphalab.data.validation.coerce_row(table: 'RawTable', row: 'RawRow', detection: 'SchemaDetection', timezone_name: 'str | None', date_policy: 'DateOnlyPolicy | None', default_symbol: 'str | None') -> 'tuple[CanonicalRecord | None, tuple[ValidationFinding, ...]]'", + "content_digest": "function alphalab.data.source.content_digest(payload: 'bytes') -> 'str'", + "create_dataset": "function alphalab.data.loader.create_dataset(metadata: alphalab.data.metadata.DatasetMetadata, bars: tuple[alphalab.data.feed.Bar, ...]) -> alphalab.data.dataset.Dataset", + "dataset_lineage": "function alphalab.data.views.dataset_lineage(state: alphalab.data.state.UniversalDataState, dataset_id: str) -> tuple[str, ...]", + "dataset_provenance": "function alphalab.data.views.dataset_provenance(state: alphalab.data.state.UniversalDataState, dataset_id: str) -> alphalab.data.provenance.DatasetProvenance | None", + "dataset_summary": "function alphalab.data.views.dataset_summary(state: alphalab.data.state.UniversalDataState) -> collections.abc.Sequence[alphalab.data.dataset.Dataset]", + "declare_trade_schema": "function alphalab.data.schema.declare_trade_schema(table: 'RawTable', columns: 'TradeColumns') -> 'SchemaDetection'", + "decode_text": "function alphalab.data.csv_source.decode_text(payload: 'bytes', encoding: 'str') -> 'str'", + "derive_dataset_version": "function alphalab.data.provenance.derive_dataset_version(name: 'str', content_hash: 'str', schema: 'DatasetSchema', timezone_name: 'str', calendar_id: 'str | None', frequency: 'TimeFrequency', price_basis: 'PriceBasis', cleaning_policy: 'CleaningPolicy', transformations: 'Sequence[TransformationRecord]', adjustments: 'Sequence[AdjustmentRecord]') -> 'str'", + "derive_transformed_version": "function alphalab.data.provenance.derive_transformed_version(parent_version: 'str', transformations: 'Sequence[TransformationRecord]') -> 'str'", + "detect_delimiter": "function alphalab.data.csv_source.detect_delimiter(text: 'str') -> 'tuple[str | None, str]'", + "detect_schema": "function alphalab.data.schema.detect_schema(table: 'RawTable', declared_record_type: 'RecordType | None' = None) -> 'SchemaDetection'", + "detect_timestamp_format": "function alphalab.data.time.detect_timestamp_format(values: 'Sequence[str]') -> 'tuple[TimestampFormat | None, str]'", + "duplicate_key": "function alphalab.data.validation.duplicate_key(record: 'CanonicalRecord') -> 'tuple[object, ...] | None'", + "evaluate_bar_quality": "function alphalab.data.quality.evaluate_bar_quality(dataset_id: 'str', bars: 'tuple[Bar, ...]') -> 'QualityReport'", + "evaluate_quality": "function alphalab.data.quality.evaluate_quality(dataset_id: 'str', records: 'Sequence[CanonicalRecord]', row_count: 'int', rejected: 'Sequence[RowRejection]', findings: 'Sequence[ValidationFinding]') -> 'DataQualityReport'", + "frequency_seconds": "function alphalab.data.time.frequency_seconds(frequency: 'TimeFrequency') -> 'float | None'", + "infer_frequency": "function alphalab.data.time.infer_frequency(timestamps: 'Sequence[float]') -> 'tuple[TimeFrequency | None, str]'", + "ingest_table": "function alphalab.data.ingestion.ingest_table(table: 'RawTable', request: 'IngestionRequest', *, content_hash: 'str | None' = None) -> 'IngestionResult'", + "is_internally_consistent": "function alphalab.data.cleaning.is_internally_consistent(record: 'CanonicalRecord') -> 'bool'", + "metadata_view": "function alphalab.data.views.metadata_view(state: alphalab.data.state.UniversalDataState, dataset_id: str) -> alphalab.data.metadata.DatasetMetadata | None", + "normalize_prices": "function alphalab.data.normalization.normalize_prices(bars: tuple[alphalab.data.feed.Bar, ...], factor: float) -> tuple[alphalab.data.feed.Bar, ...]", + "parse_and_load": "function alphalab.data.ingestion.parse_and_load(metadata: 'DatasetMetadata', raw_rows: 'Sequence[Mapping[str, Any]]') -> 'Dataset'", + "parse_raw_rows": "function alphalab.data.parser.parse_raw_rows(symbol: str, raw_rows: collections.abc.Sequence[collections.abc.Mapping[str, Any]]) -> tuple[alphalab.data.feed.Bar, ...]", + "parse_timestamp": "function alphalab.data.time.parse_timestamp(value: 'str', timestamp_format: 'TimestampFormat', timezone_name: 'str | None', date_policy: 'DateOnlyPolicy | None') -> 'float'", + "quality_report": "function alphalab.data.views.quality_report(state: alphalab.data.state.UniversalDataState, dataset_id: str) -> alphalab.data.quality.QualityReport | None", + "raw_source_from_bytes": "function alphalab.data.source.raw_source_from_bytes(kind: 'SourceKind', location: 'str', payload: 'bytes', retrieved_at: 'float', media_type: 'str', encoding: 'str | None' = None, attributes: 'MetadataMapping | None' = None) -> 'RawSource'", + "raw_source_from_path": "function alphalab.data.source.raw_source_from_path(path: 'str | Path', retrieved_at: 'float', media_type: 'str', encoding: 'str' = 'utf-8') -> 'tuple[RawSource, bytes]'", + "read_delimited": "function alphalab.data.csv_source.read_delimited(text: 'str', dialect: 'CsvDialect | None') -> 'RawTable'", + "remove_duplicates": "function alphalab.data.cleaning.remove_duplicates(bars: 'tuple[Bar, ...]') -> 'tuple[Bar, ...]'", + "remove_invalid_ohlc": "function alphalab.data.cleaning.remove_invalid_ohlc(bars: 'tuple[Bar, ...]') -> 'tuple[Bar, ...]'", + "resample_bars": "function alphalab.data.conversion.resample_bars(bars: tuple[alphalab.data.feed.Bar, ...], interval_seconds: float) -> tuple[alphalab.data.feed.Bar, ...]", + "resolve_zone": "function alphalab.data.time.resolve_zone(timezone_name: 'str') -> 'ZoneInfo'", + "schema_report": "function alphalab.data.views.schema_report(state: alphalab.data.state.UniversalDataState, dataset_id: str) -> alphalab.data.schema.DatasetSchema | None", + "validate_dataset_ingestion": "function alphalab.data.manager.validate_dataset_ingestion(state: alphalab.data.state.UniversalDataState, dataset: alphalab.data.dataset.Dataset) -> None", + "validate_records": "function alphalab.data.validation.validate_records(records: 'Sequence[CanonicalRecord]', expected_frequency: 'TimeFrequency | None') -> 'tuple[ValidationFinding, ...]'" + }, + "alphalab.deep_learning": { + "ActivationType": "enum alphalab.deep_learning.activations.ActivationType[LINEAR, RELU, SIGMOID, TANH]", + "AttentionGradients": "class alphalab.deep_learning.attention.AttentionGradients(queries: tuple[tuple[float, ...], ...], keys: tuple[tuple[float, ...], ...], values: tuple[tuple[float, ...], ...]) -> None", + "AttentionOutput": "class alphalab.deep_learning.attention.AttentionOutput(context: tuple[tuple[float, ...], ...], weights: tuple[tuple[float, ...], ...]) -> None", + "Conv1DForwardCache": "class alphalab.deep_learning.conv1d.Conv1DForwardCache(input: tuple[float, ...], pre_activation: tuple[float, ...], output: tuple[float, ...]) -> None", + "Conv1DGradients": "class alphalab.deep_learning.conv1d.Conv1DGradients(kernel_gradients: tuple[float, ...], bias_gradient: float, input_gradients: tuple[float, ...]) -> None", + "Conv1DLayer": "class alphalab.deep_learning.conv1d.Conv1DLayer(kernel: tuple[float, ...], bias: float, activation: alphalab.deep_learning.activations.ActivationType) -> None", + "DLComputationError": "class alphalab.deep_learning.exceptions.DLComputationError", + "DLInputError": "class alphalab.deep_learning.exceptions.DLInputError", + "DeepLearningError": "class alphalab.deep_learning.exceptions.DeepLearningError", + "DenseForwardCache": "class alphalab.deep_learning.dense.DenseForwardCache(input: tuple[float, ...], pre_activation: tuple[float, ...], output: tuple[float, ...]) -> None", + "DenseGradients": "class alphalab.deep_learning.dense.DenseGradients(weight_gradients: tuple[tuple[float, ...], ...], bias_gradients: tuple[float, ...], input_gradients: tuple[float, ...]) -> None", + "DenseLayer": "class alphalab.deep_learning.dense.DenseLayer(weights: tuple[tuple[float, ...], ...], bias: tuple[float, ...], activation: alphalab.deep_learning.activations.ActivationType) -> None", + "LSTMCell": "class alphalab.deep_learning.lstm.LSTMCell(forget_gate: alphalab.deep_learning.dense.DenseLayer, input_gate: alphalab.deep_learning.dense.DenseLayer, candidate_gate: alphalab.deep_learning.dense.DenseLayer, output_gate: alphalab.deep_learning.dense.DenseLayer, hidden_size: int) -> None", + "LSTMGradients": "class alphalab.deep_learning.lstm.LSTMGradients(forget_gate: alphalab.deep_learning.dense.DenseGradients, input_gate: alphalab.deep_learning.dense.DenseGradients, candidate_gate: alphalab.deep_learning.dense.DenseGradients, output_gate: alphalab.deep_learning.dense.DenseGradients, input_gradients: tuple[tuple[float, ...], ...], initial_hidden: tuple[float, ...], initial_cell: tuple[float, ...]) -> None", + "LSTMRegressor": "class alphalab.deep_learning.lstm.LSTMRegressor(cell: alphalab.deep_learning.lstm.LSTMCell, head: alphalab.deep_learning.dense.DenseLayer) -> None", + "LSTMState": "class alphalab.deep_learning.lstm.LSTMState(hidden: tuple[float, ...], cell: tuple[float, ...]) -> None", + "LSTMStepCache": "class alphalab.deep_learning.lstm.LSTMStepCache(previous: alphalab.deep_learning.lstm.LSTMState, forget: alphalab.deep_learning.dense.DenseForwardCache, input: alphalab.deep_learning.dense.DenseForwardCache, candidate: alphalab.deep_learning.dense.DenseForwardCache, output: alphalab.deep_learning.dense.DenseForwardCache, state: alphalab.deep_learning.lstm.LSTMState, cell_tanh: tuple[float, ...]) -> None", + "SelfAttentionCache": "class alphalab.deep_learning.attention.SelfAttentionCache(query: tuple[alphalab.deep_learning.dense.DenseForwardCache, ...], key: tuple[alphalab.deep_learning.dense.DenseForwardCache, ...], value: tuple[alphalab.deep_learning.dense.DenseForwardCache, ...], attention: alphalab.deep_learning.attention.AttentionOutput) -> None", + "SelfAttentionGradients": "class alphalab.deep_learning.attention.SelfAttentionGradients(query: alphalab.deep_learning.dense.DenseGradients, key: alphalab.deep_learning.dense.DenseGradients, value: alphalab.deep_learning.dense.DenseGradients, input_gradients: tuple[tuple[float, ...], ...]) -> None", + "SelfAttentionLayer": "class alphalab.deep_learning.attention.SelfAttentionLayer(query: alphalab.deep_learning.dense.DenseLayer, key: alphalab.deep_learning.dense.DenseLayer, value: alphalab.deep_learning.dense.DenseLayer) -> None", + "Sequential": "class alphalab.deep_learning.network.Sequential(layers: tuple[alphalab.deep_learning.dense.DenseLayer, ...]) -> None", + "activation_derivative": "function alphalab.deep_learning.activations.activation_derivative(activation: alphalab.deep_learning.activations.ActivationType, z: float) -> float", + "apply_activation": "function alphalab.deep_learning.activations.apply_activation(activation: alphalab.deep_learning.activations.ActivationType, z: float) -> float", + "apply_conv1d_gradients": "function alphalab.deep_learning.conv1d.apply_conv1d_gradients(layer: alphalab.deep_learning.conv1d.Conv1DLayer, gradients: alphalab.deep_learning.conv1d.Conv1DGradients, learning_rate: float) -> alphalab.deep_learning.conv1d.Conv1DLayer", + "apply_gradients": "function alphalab.deep_learning.dense.apply_gradients(layer: alphalab.deep_learning.dense.DenseLayer, gradients: alphalab.deep_learning.dense.DenseGradients, learning_rate: float) -> alphalab.deep_learning.dense.DenseLayer", + "apply_lstm_gradients": "function alphalab.deep_learning.lstm.apply_lstm_gradients(cell: alphalab.deep_learning.lstm.LSTMCell, gradients: alphalab.deep_learning.lstm.LSTMGradients, learning_rate: float) -> alphalab.deep_learning.lstm.LSTMCell", + "apply_self_attention_gradients": "function alphalab.deep_learning.attention.apply_self_attention_gradients(layer: alphalab.deep_learning.attention.SelfAttentionLayer, gradients: alphalab.deep_learning.attention.SelfAttentionGradients, learning_rate: float) -> alphalab.deep_learning.attention.SelfAttentionLayer", + "backward_conv1d": "function alphalab.deep_learning.conv1d.backward_conv1d(layer: alphalab.deep_learning.conv1d.Conv1DLayer, cache: alphalab.deep_learning.conv1d.Conv1DForwardCache, grad_output: tuple[float, ...]) -> alphalab.deep_learning.conv1d.Conv1DGradients", + "backward_dense": "function alphalab.deep_learning.dense.backward_dense(layer: alphalab.deep_learning.dense.DenseLayer, cache: alphalab.deep_learning.dense.DenseForwardCache, grad_output: tuple[float, ...]) -> alphalab.deep_learning.dense.DenseGradients", + "backward_self_attention": "function alphalab.deep_learning.attention.backward_self_attention(layer: alphalab.deep_learning.attention.SelfAttentionLayer, cache: alphalab.deep_learning.attention.SelfAttentionCache, grad_context: tuple[tuple[float, ...], ...]) -> alphalab.deep_learning.attention.SelfAttentionGradients", + "create_conv1d_layer": "function alphalab.deep_learning.conv1d.create_conv1d_layer(kernel_size: int, activation: alphalab.deep_learning.activations.ActivationType, seed: int) -> alphalab.deep_learning.conv1d.Conv1DLayer", + "create_dense_layer": "function alphalab.deep_learning.dense.create_dense_layer(n_inputs: int, n_outputs: int, activation: alphalab.deep_learning.activations.ActivationType, seed: int) -> alphalab.deep_learning.dense.DenseLayer", + "create_lstm_cell": "function alphalab.deep_learning.lstm.create_lstm_cell(input_size: int, hidden_size: int, seed: int) -> alphalab.deep_learning.lstm.LSTMCell", + "create_lstm_regressor": "function alphalab.deep_learning.lstm.create_lstm_regressor(input_size: int, hidden_size: int, output_size: int, seed: int, head_activation: alphalab.deep_learning.activations.ActivationType = ) -> alphalab.deep_learning.lstm.LSTMRegressor", + "create_self_attention_layer": "function alphalab.deep_learning.attention.create_self_attention_layer(input_size: int, key_size: int, value_size: int, seed: int) -> alphalab.deep_learning.attention.SelfAttentionLayer", + "forward_conv1d": "function alphalab.deep_learning.conv1d.forward_conv1d(layer: alphalab.deep_learning.conv1d.Conv1DLayer, x: tuple[float, ...]) -> alphalab.deep_learning.conv1d.Conv1DForwardCache", + "forward_dense": "function alphalab.deep_learning.dense.forward_dense(layer: alphalab.deep_learning.dense.DenseLayer, x: tuple[float, ...]) -> alphalab.deep_learning.dense.DenseForwardCache", + "forward_network": "function alphalab.deep_learning.network.forward_network(network: alphalab.deep_learning.network.Sequential, x: tuple[float, ...]) -> tuple[tuple[alphalab.deep_learning.dense.DenseForwardCache, ...], tuple[float, ...]]", + "forward_self_attention": "function alphalab.deep_learning.attention.forward_self_attention(layer: alphalab.deep_learning.attention.SelfAttentionLayer, sequence: tuple[tuple[float, ...], ...]) -> alphalab.deep_learning.attention.SelfAttentionCache", + "gradients_match": "function alphalab.deep_learning.gradient_check.gradients_match(analytical: tuple[float, ...], numerical: tuple[float, ...], rtol: float = 0.01, atol: float = 0.0001) -> bool", + "initial_lstm_state": "function alphalab.deep_learning.lstm.initial_lstm_state(hidden_size: int) -> alphalab.deep_learning.lstm.LSTMState", + "lstm_backward": "function alphalab.deep_learning.lstm.lstm_backward(cell: alphalab.deep_learning.lstm.LSTMCell, steps: tuple[alphalab.deep_learning.lstm.LSTMStepCache, ...], grad_hidden: tuple[tuple[float, ...], ...], grad_final_cell: tuple[float, ...] | None = None) -> alphalab.deep_learning.lstm.LSTMGradients", + "lstm_forward_sequence": "function alphalab.deep_learning.lstm.lstm_forward_sequence(cell: alphalab.deep_learning.lstm.LSTMCell, x_sequence: tuple[tuple[float, ...], ...], initial_state: alphalab.deep_learning.lstm.LSTMState | None = None) -> tuple[alphalab.deep_learning.lstm.LSTMState, ...]", + "lstm_forward_step": "function alphalab.deep_learning.lstm.lstm_forward_step(cell: alphalab.deep_learning.lstm.LSTMCell, x: tuple[float, ...], prev_state: alphalab.deep_learning.lstm.LSTMState) -> alphalab.deep_learning.lstm.LSTMState", + "lstm_sequence": "function alphalab.deep_learning.lstm.lstm_sequence(cell: alphalab.deep_learning.lstm.LSTMCell, x_sequence: tuple[tuple[float, ...], ...], initial_state: alphalab.deep_learning.lstm.LSTMState | None = None) -> tuple[alphalab.deep_learning.lstm.LSTMStepCache, ...]", + "lstm_step": "function alphalab.deep_learning.lstm.lstm_step(cell: alphalab.deep_learning.lstm.LSTMCell, x: tuple[float, ...], prev_state: alphalab.deep_learning.lstm.LSTMState) -> alphalab.deep_learning.lstm.LSTMStepCache", + "numerical_gradient": "function alphalab.deep_learning.gradient_check.numerical_gradient(f: collections.abc.Callable[[tuple[float, ...]], float], params: tuple[float, ...], epsilon: float = 1e-05) -> tuple[float, ...]", + "predict_lstm": "function alphalab.deep_learning.lstm.predict_lstm(model: alphalab.deep_learning.lstm.LSTMRegressor, x_sequence: tuple[tuple[float, ...], ...]) -> tuple[float, ...]", + "predict_network": "function alphalab.deep_learning.network.predict_network(network: alphalab.deep_learning.network.Sequential, x: tuple[float, ...]) -> tuple[float, ...]", + "relu": "function alphalab.deep_learning.activations.relu(x: float) -> float", + "relu_derivative": "function alphalab.deep_learning.activations.relu_derivative(x: float) -> float", + "scaled_dot_product_attention": "function alphalab.deep_learning.attention.scaled_dot_product_attention(queries: tuple[tuple[float, ...], ...], keys: tuple[tuple[float, ...], ...], values: tuple[tuple[float, ...], ...]) -> alphalab.deep_learning.attention.AttentionOutput", + "scaled_dot_product_attention_backward": "function alphalab.deep_learning.attention.scaled_dot_product_attention_backward(queries: tuple[tuple[float, ...], ...], keys: tuple[tuple[float, ...], ...], values: tuple[tuple[float, ...], ...], output: alphalab.deep_learning.attention.AttentionOutput, grad_context: tuple[tuple[float, ...], ...]) -> alphalab.deep_learning.attention.AttentionGradients", + "sigmoid": "function alphalab.deep_learning.activations.sigmoid(x: float) -> float", + "sigmoid_derivative": "function alphalab.deep_learning.activations.sigmoid_derivative(x: float) -> float", + "softmax": "function alphalab.deep_learning.activations.softmax(values: tuple[float, ...]) -> tuple[float, ...]", + "tanh_activation": "function alphalab.deep_learning.activations.tanh_activation(x: float) -> float", + "tanh_derivative": "function alphalab.deep_learning.activations.tanh_derivative(x: float) -> float", + "train_lstm": "function alphalab.deep_learning.lstm.train_lstm(model: alphalab.deep_learning.lstm.LSTMRegressor, sequences: tuple[tuple[tuple[float, ...], ...], ...], targets: tuple[tuple[float, ...], ...], learning_rate: float, epochs: int) -> tuple[alphalab.deep_learning.lstm.LSTMRegressor, tuple[float, ...]]", + "train_lstm_step": "function alphalab.deep_learning.lstm.train_lstm_step(model: alphalab.deep_learning.lstm.LSTMRegressor, x_sequence: tuple[tuple[float, ...], ...], target: tuple[float, ...], learning_rate: float) -> tuple[alphalab.deep_learning.lstm.LSTMRegressor, float]", + "train_network": "function alphalab.deep_learning.network.train_network(network: alphalab.deep_learning.network.Sequential, x: tuple[tuple[float, ...], ...], y: tuple[tuple[float, ...], ...], learning_rate: float, epochs: int) -> tuple[alphalab.deep_learning.network.Sequential, tuple[float, ...]]", + "train_step": "function alphalab.deep_learning.network.train_step(network: alphalab.deep_learning.network.Sequential, x: tuple[float, ...], target: tuple[float, ...], learning_rate: float) -> tuple[alphalab.deep_learning.network.Sequential, float]" + }, + "alphalab.deployment_manager": { + "DeploymentManager": "class alphalab.deployment_manager.releases.DeploymentManager(releases: alphalab.common.persistent_map.PersistentMap[str, alphalab.common.append_log.AppendOnlyLog[alphalab.deployment_manager.packaging.ReleasePackage]] = , deployments: alphalab.common.append_log.AppendOnlyLog[alphalab.deployment_manager.releases.DeploymentRecord] = , environments: alphalab.common.persistent_map.PersistentMap[str, alphalab.common.append_log.AppendOnlyLog[alphalab.deployment_manager.releases.DeploymentRecord]] = ) -> None", + "DeploymentManagerError": "class alphalab.deployment_manager.exceptions.DeploymentManagerError", + "DeploymentManagerInputError": "class alphalab.deployment_manager.exceptions.DeploymentManagerInputError", + "DeploymentRecord": "class alphalab.deployment_manager.releases.DeploymentRecord(environment: str, release_name: str, version: int, replaced_version: int | None, is_rollback: bool, timestamp: float, actor_id: str = '') -> None", + "ReleasePackage": "class alphalab.deployment_manager.packaging.ReleasePackage(name: str, version: int, components: collections.abc.Mapping[str, str], config: collections.abc.Mapping[str, str], checksum: str, created_at: float = 0.0) -> None", + "active_release": "function alphalab.deployment_manager.deployment.active_release(manager: alphalab.deployment_manager.releases.DeploymentManager, environment: str) -> alphalab.deployment_manager.packaging.ReleasePackage | None", + "build_release": "function alphalab.deployment_manager.packaging.build_release(name: str, version: int, components: collections.abc.Mapping[str, str], config: collections.abc.Mapping[str, str], timestamp: float) -> alphalab.deployment_manager.packaging.ReleasePackage", + "compute_checksum": "function alphalab.deployment_manager.packaging.compute_checksum(components: collections.abc.Mapping[str, str], config: collections.abc.Mapping[str, str]) -> str", + "deploy": "function alphalab.deployment_manager.deployment.deploy(manager: alphalab.deployment_manager.releases.DeploymentManager, name: str, version: int, environment: str, timestamp: float, actor_id: str = '') -> alphalab.deployment_manager.releases.DeploymentManager", + "deployed_environments": "function alphalab.deployment_manager.deployment.deployed_environments(manager: alphalab.deployment_manager.releases.DeploymentManager) -> tuple[str, ...]", + "deployment_history": "function alphalab.deployment_manager.rollback.deployment_history(manager: alphalab.deployment_manager.releases.DeploymentManager, environment: str | None = None) -> tuple[alphalab.deployment_manager.releases.DeploymentRecord, ...]", + "get_release": "function alphalab.deployment_manager.releases.get_release(manager: alphalab.deployment_manager.releases.DeploymentManager, name: str, version: int) -> alphalab.deployment_manager.packaging.ReleasePackage", + "latest_release": "function alphalab.deployment_manager.releases.latest_release(manager: alphalab.deployment_manager.releases.DeploymentManager, name: str) -> alphalab.deployment_manager.packaging.ReleasePackage", + "list_releases": "function alphalab.deployment_manager.releases.list_releases(manager: alphalab.deployment_manager.releases.DeploymentManager, name: str) -> tuple[alphalab.deployment_manager.packaging.ReleasePackage, ...]", + "previous_release": "function alphalab.deployment_manager.rollback.previous_release(manager: alphalab.deployment_manager.releases.DeploymentManager, environment: str) -> tuple[str, int] | None", + "register_release": "function alphalab.deployment_manager.releases.register_release(manager: alphalab.deployment_manager.releases.DeploymentManager, name: str, components: collections.abc.Mapping[str, str], config: collections.abc.Mapping[str, str], timestamp: float) -> tuple[alphalab.deployment_manager.releases.DeploymentManager, alphalab.deployment_manager.packaging.ReleasePackage]", + "release_names": "function alphalab.deployment_manager.releases.release_names(manager: alphalab.deployment_manager.releases.DeploymentManager) -> tuple[str, ...]", + "rollback": "function alphalab.deployment_manager.rollback.rollback(manager: alphalab.deployment_manager.releases.DeploymentManager, environment: str, timestamp: float, actor_id: str = '') -> alphalab.deployment_manager.releases.DeploymentManager", + "verify_checksum": "function alphalab.deployment_manager.packaging.verify_checksum(package: alphalab.deployment_manager.packaging.ReleasePackage) -> bool" + }, + "alphalab.distributed": { + "DistributedAdapter": "class alphalab.distributed.adapter.DistributedAdapter()", + "DistributedEngine": "class alphalab.distributed.engine.DistributedEngine()", + "DistributedError": "class alphalab.distributed.exceptions.DistributedError", + "DistributedEvent": "class alphalab.distributed.events.DistributedEvent(event_id: str, timestamp: float) -> None", + "DistributedState": "class alphalab.distributed.state.DistributedState(cluster_id: str, workers: alphalab.common.persistent_map.PersistentMap[str, alphalab.distributed.node.WorkerNode] = , queued_jobs: alphalab.common.append_log.AppendOnlyLog[alphalab.distributed.job.Job] = , queued_ids: alphalab.common.persistent_map.PersistentSet[str] = , running_jobs: alphalab.common.persistent_map.PersistentMap[str, alphalab.distributed.job.Job] = , completed_jobs: alphalab.common.persistent_map.PersistentMap[str, alphalab.distributed.job.Job] = , failed_jobs: alphalab.common.persistent_map.PersistentMap[str, alphalab.distributed.job.Job] = , cancelled_jobs: alphalab.common.persistent_map.PersistentMap[str, alphalab.distributed.job.Job] = , statistics: alphalab.distributed.state.DistributedStatistics = , events: alphalab.common.append_log.AppendOnlyLog[alphalab.distributed.events.DistributedEvent] = , metadata: collections.abc.Mapping[str, str] = ) -> None", + "DistributedStatistics": "class alphalab.distributed.state.DistributedStatistics(total_jobs_submitted: int = 0, total_jobs_completed: int = 0, total_jobs_failed: int = 0, total_jobs_cancelled: int = 0) -> None", + "DistributedValidationError": "class alphalab.distributed.exceptions.DistributedValidationError", + "InvalidJobStateError": "class alphalab.distributed.exceptions.InvalidJobStateError", + "InvalidNodeStateError": "class alphalab.distributed.exceptions.InvalidNodeStateError", + "Job": "class alphalab.distributed.job.Job(job_id: str, job_type: alphalab.distributed.job.JobType, status: alphalab.distributed.job.JobStatus, priority: int, created_timestamp: float, started_timestamp: float = 0.0, completed_timestamp: float = 0.0, worker_id: str | None = None, payload: collections.abc.Mapping[str, Any] = , metadata: collections.abc.Mapping[str, str] = ) -> None", + "JobAssigned": "class alphalab.distributed.events.JobAssigned(event_id: str, timestamp: float, job_id: str, worker_id: str) -> None", + "JobCancelled": "class alphalab.distributed.events.JobCancelled(event_id: str, timestamp: float, job_id: str, worker_id: str) -> None", + "JobCompleted": "class alphalab.distributed.events.JobCompleted(event_id: str, timestamp: float, job_id: str, worker_id: str, execution_time: float) -> None", + "JobCoordinator": "class alphalab.distributed.coordinator.JobCoordinator()", + "JobFailed": "class alphalab.distributed.events.JobFailed(event_id: str, timestamp: float, job_id: str, worker_id: str, reason: str) -> None", + "JobQueue": "class alphalab.distributed.queue.JobQueue()", + "JobRunnerProtocol": "class alphalab.distributed.protocol.JobRunnerProtocol(*args, **kwargs)", + "JobScheduler": "class alphalab.distributed.scheduler.JobScheduler()", + "JobStarted": "class alphalab.distributed.events.JobStarted(event_id: str, timestamp: float, job_id: str, worker_id: str) -> None", + "JobStatus": "enum alphalab.distributed.job.JobStatus[PENDING, ASSIGNED, RUNNING, COMPLETED, FAILED, CANCELLED]", + "JobSubmitted": "class alphalab.distributed.events.JobSubmitted(event_id: str, timestamp: float, job_id: str, job_type: str, priority: int) -> None", + "JobType": "enum alphalab.distributed.job.JobType[BACKTEST, OPTIMIZATION, REPLAY, ANALYTICS, REPORT, PLUGIN_TASK]", + "WorkerNode": "class alphalab.distributed.node.WorkerNode(node_id: str, hostname: str, status: alphalab.distributed.node.WorkerStatus, capacity: int, running_jobs: tuple[str, ...] = , completed_jobs: int = 0, metadata: collections.abc.Mapping[str, str] = ) -> None", + "WorkerRegistered": "class alphalab.distributed.events.WorkerRegistered(event_id: str, timestamp: float, worker_id: str, capacity: int) -> None", + "WorkerRegistry": "class alphalab.distributed.registry.WorkerRegistry()", + "WorkerRemoved": "class alphalab.distributed.events.WorkerRemoved(event_id: str, timestamp: float, worker_id: str) -> None", + "WorkerStatus": "enum alphalab.distributed.node.WorkerStatus[IDLE, BUSY, OFFLINE]", + "active_workers": "function alphalab.distributed.views.active_workers(state: alphalab.distributed.state.DistributedState) -> collections.abc.Sequence[alphalab.distributed.node.WorkerNode]", + "cancelled_jobs": "function alphalab.distributed.views.cancelled_jobs(state: alphalab.distributed.state.DistributedState) -> collections.abc.Sequence[alphalab.distributed.job.Job]", + "cluster_statistics": "function alphalab.distributed.views.cluster_statistics(state: alphalab.distributed.state.DistributedState) -> alphalab.distributed.state.DistributedStatistics", + "completed_jobs": "function alphalab.distributed.views.completed_jobs(state: alphalab.distributed.state.DistributedState) -> collections.abc.Sequence[alphalab.distributed.job.Job]", + "failed_jobs": "function alphalab.distributed.views.failed_jobs(state: alphalab.distributed.state.DistributedState) -> collections.abc.Sequence[alphalab.distributed.job.Job]", + "queue_length": "function alphalab.distributed.views.queue_length(state: alphalab.distributed.state.DistributedState) -> int", + "running_jobs": "function alphalab.distributed.views.running_jobs(state: alphalab.distributed.state.DistributedState) -> collections.abc.Sequence[alphalab.distributed.job.Job]", + "validate_job_submission": "function alphalab.distributed.validation.validate_job_submission(state: alphalab.distributed.state.DistributedState, job: alphalab.distributed.job.Job) -> None", + "validate_job_transition": "function alphalab.distributed.validation.validate_job_transition(job: alphalab.distributed.job.Job, target_status: alphalab.distributed.job.JobStatus) -> None", + "validate_worker_registration": "function alphalab.distributed.validation.validate_worker_registration(state: alphalab.distributed.state.DistributedState, worker: alphalab.distributed.node.WorkerNode) -> None", + "worker_utilization": "function alphalab.distributed.views.worker_utilization(state: alphalab.distributed.state.DistributedState) -> dict[str, float]" + }, + "alphalab.execution": { + "ALGORITHM_CONFIGURATION_SCHEME": "value builtins.str", + "ALGORITHM_RUN_SCHEME": "value builtins.str", + "AlgorithmState": "class alphalab.execution.algorithms.AlgorithmState(parent: 'OrderRequest', algorithm: 'ExecutionAlgorithm', terms: 'AlgorithmTerms', algorithm_id: 'str', schedule: 'ExecutionSchedule | None', children: 'PersistentMap[str, ChildProgress]' = , released: 'Decimal' = Decimal('0'), filled: 'Decimal' = Decimal('0'), returned: 'Decimal' = Decimal('0'), executions: 'PersistentSet[str]' = , observed_volume: 'Decimal' = Decimal('0'), observed_until: 'float | None' = None, status: 'AlgorithmStatus' = , reason: 'str' = '', notes: 'tuple[str, ...]' = ()) -> None", + "AlgorithmStatus": "enum alphalab.execution.algorithms.AlgorithmStatus[WORKING, COMPLETED, CANCELLED, EXPIRED, FAILED]", + "AlgorithmTerms": "class alphalab.execution.algorithms.AlgorithmTerms(start: 'float', end: 'float', quantity_increment: 'Decimal', order_type: 'OrderType', limit_price: 'Decimal | None') -> None", + "AssetCapacity": "class alphalab.execution.capacity.AssetCapacity(asset_id: 'str', participation_capacity: 'Decimal', impact_capacity: 'Decimal | None', capacity: 'Decimal', constraint: 'CapacityConstraint', participation_at_capacity: 'Decimal') -> None", + "AssetLiquidity": "class alphalab.execution.capacity.AssetLiquidity(asset_id: 'str', price: 'Decimal', average_daily_volume: 'Decimal', weight: 'Decimal', currency: 'str', venue: 'str') -> None", + "CandidateStatus": "enum alphalab.execution.routing.CandidateStatus[ELIGIBLE, INELIGIBLE, INSUFFICIENT_EVIDENCE, EXCLUDED]", + "CapacityConstraint": "enum alphalab.execution.capacity.CapacityConstraint[PARTICIPATION, IMPACT_BUDGET, UNBOUND]", + "CapacityModel": "class alphalab.execution.capacity.CapacityModel(participation_limit: 'Decimal', turnover: 'Decimal', impact_model: 'ImpactModel', impact_budget: 'Decimal | None', search_ceiling: 'Decimal') -> None", + "CapacityResult": "class alphalab.execution.capacity.CapacityResult(capacity: 'Decimal', constraint: 'CapacityConstraint', binding_asset_id: 'str | None', per_asset: 'tuple[AssetCapacity, ...]', assumptions: 'Mapping[str, str]') -> None", + "ChildOrder": "class alphalab.execution.algorithms.ChildOrder(child_id: 'str', parent_order_id: 'str', sequence: 'int', asset_id: 'str', side: 'Side', quantity: 'Decimal', order_type: 'OrderType', limit_price: 'Decimal | None', released_at: 'float', expires_at: 'float | None', strategy_id: 'str', contributions: 'tuple[StrategyContribution, ...]', algorithm_id: 'str') -> None", + "ChildProgress": "class alphalab.execution.algorithms.ChildProgress(child: 'ChildOrder', filled: 'Decimal', outcome: 'OrderStatus | None') -> None", + "ClockSource": "enum alphalab.execution.quality.ClockSource[LOCAL, VENUE]", + "CommissionModel": "class alphalab.execution.commission.CommissionModel(*args, **kwargs)", + "ConstantLatency": "class alphalab.execution.latency.ConstantLatency(latency: 'float') -> 'None'", + "CostContext": "class alphalab.execution.costs.CostContext(asset_id: 'str', side: 'Side', quantity: 'Decimal', reference_price: 'Decimal', currency: 'str', venue: 'str', timestamp: 'float', bid: 'Decimal | None' = None, ask: 'Decimal | None' = None, available_liquidity: 'Decimal | None' = None, minor_units: 'int | None' = None) -> None", + "CostSettlement": "enum alphalab.execution.costs.CostSettlement[PRICE_EMBEDDED, CASH_CHARGED]", + "DeterministicLatency": "class alphalab.execution.latency.DeterministicLatency(min_lat: 'float', max_lat: 'float') -> 'None'", + "EXECUTION_QUALITY_SCHEME": "value builtins.str", + "EXECUTION_SCHEDULE_SCHEME": "value builtins.str", + "ExecutionAlgorithm": "alias ExecutionAlgorithm", + "ExecutionBenchmarks": "class alphalab.execution.quality.ExecutionBenchmarks(decision_price: 'Decimal | None', arrival_price: 'Decimal | None', interval_vwap: 'Decimal | None', end_price: 'Decimal | None', source: 'str') -> None", + "ExecutionCompleted": "class alphalab.execution.events.ExecutionCompleted(event_id: str, timestamp: float, order_id: str, execution_id: str, fill_price: decimal.Decimal, fill_quantity: decimal.Decimal) -> None", + "ExecutionCostModel": "class alphalab.execution.costs.ExecutionCostModel(spread_model: 'SpreadModel', slippage_model: 'SlippageModel', impact_model: 'ImpactModel', commission_model: 'CommissionModel', fee_model: 'FeeModel', tax_model: 'TaxModel') -> None", + "ExecutionCosts": "class alphalab.execution.costs.ExecutionCosts(spread: 'Decimal', slippage: 'Decimal', impact: 'Decimal', commission: 'Decimal', fees: 'Decimal', tax: 'Decimal') -> None", + "ExecutionEngine": "class alphalab.execution.engine.ExecutionEngine()", + "ExecutionError": "class alphalab.execution.exceptions.ExecutionError", + "ExecutionEvent": "class alphalab.execution.events.ExecutionEvent(event_id: str, timestamp: float, order_id: str) -> None", + "ExecutionExpired": "class alphalab.execution.events.ExecutionExpired(event_id: str, timestamp: float, order_id: str) -> None", + "ExecutionPartiallyFilled": "class alphalab.execution.events.ExecutionPartiallyFilled(event_id: str, timestamp: float, order_id: str, execution_id: str, fill_price: decimal.Decimal, fill_quantity: decimal.Decimal, remaining_quantity: decimal.Decimal) -> None", + "ExecutionQualityReport": "class alphalab.execution.quality.ExecutionQualityReport(shortfalls: 'tuple[ImplementationShortfall, ...]', total_by_currency: 'Mapping[str, Decimal]', reporting_currency: 'str | None', reporting_total: 'Decimal | None', conversions: 'tuple[ConversionRecord, ...]', conversion_at: 'float | None', strategy_totals: 'Mapping[str, Mapping[str, Decimal]]', incomplete_orders: 'tuple[str, ...]', assumptions: 'tuple[str, ...]') -> None", + "ExecutionRejected": "class alphalab.execution.events.ExecutionRejected(event_id: str, timestamp: float, order_id: str, reason: str) -> None", + "ExecutionReport": "class alphalab.execution.report.ExecutionReport(execution_id: str, order_id: str, asset_id: str, strategy_id: str, timestamp: float, fill_price: decimal.Decimal, fill_quantity: decimal.Decimal, commission: decimal.Decimal, slippage: decimal.Decimal, liquidity_flag: str, venue: str, currency: str, status: alphalab.execution.fill.FillStatus) -> None", + "ExecutionSchedule": "class alphalab.execution.algorithms.ExecutionSchedule(slices: 'tuple[ScheduledSlice, ...]', basis: 'ScheduleBasis', note: 'str') -> None", + "ExecutionSimulator": "class alphalab.execution.simulator.ExecutionSimulator(commission_model: alphalab.execution.commission.CommissionModel = , slippage_model: alphalab.execution.slippage.SlippageModel = , latency_model: alphalab.execution.latency.LatencyModel = , cost_model: alphalab.execution.costs.ExecutionCostModel | None = None, maker_commission_model: alphalab.execution.commission.CommissionModel | None = None) -> None", + "ExecutionState": "class alphalab.execution.state.ExecutionState(reports: alphalab.common.persistent_map.PersistentMap[str, alphalab.execution.report.ExecutionReport] = , history: alphalab.common.append_log.AppendOnlyLog[alphalab.execution.report.ExecutionReport] = , events: alphalab.common.append_log.AppendOnlyLog[alphalab.execution.events.ExecutionEvent] = ) -> None", + "ExecutionSubmitted": "class alphalab.execution.events.ExecutionSubmitted(event_id: str, timestamp: float, order_id: str, asset_id: str, quantity: decimal.Decimal, price: decimal.Decimal) -> None", + "ExecutionValidationError": "class alphalab.execution.exceptions.ExecutionValidationError", + "FREE": "value alphalab.execution.costs.ExecutionCostModel", + "FeeModel": "class alphalab.execution.costs.FeeModel(*args, **kwargs)", + "FillDecision": "class alphalab.execution.policy.FillDecision(status: alphalab.execution.fill.FillStatus, quantity: decimal.Decimal | None = None) -> None", + "FillPolicy": "class alphalab.execution.policy.FillPolicy(*args, **kwargs)", + "FillQuality": "class alphalab.execution.quality.FillQuality(order_id: 'str', outcome: 'OrderStatus', ordered_quantity: 'Decimal', filled_quantity: 'Decimal', fill_ratio: 'Decimal', fill_count: 'int', average_fill_price: 'Decimal | None', limit_improvement_per_unit: 'Decimal | None', effective_spread_bps: 'Decimal | None', unavailable: 'Mapping[str, str]') -> None", + "FillStatus": "enum alphalab.execution.fill.FillStatus[FULL_FILL, PARTIAL_FILL, NO_FILL, REJECTED, EXPIRED]", + "FillTiming": "enum alphalab.execution.policy.FillTiming[SAME_EVENT, NEXT_EVENT]", + "FixedCommission": "class alphalab.execution.commission.FixedCommission(fee: decimal.Decimal) -> None", + "FixedHalfSpread": "class alphalab.execution.costs.FixedHalfSpread(amount: 'Decimal') -> None", + "FixedSlippage": "class alphalab.execution.slippage.FixedSlippage(slippage_amount: decimal.Decimal) -> None", + "Iceberg": "class alphalab.execution.algorithms.Iceberg(display_quantity: 'Decimal', randomization: 'TrancheRandomization | None' = None) -> None", + "ImmediateFill": "class alphalab.execution.policy.ImmediateFill() -> None", + "ImpactModel": "class alphalab.execution.costs.ImpactModel(*args, **kwargs)", + "ImplementationShortfall": "class alphalab.execution.quality.ImplementationShortfall(order_id: 'str', asset_id: 'str', side: 'Side', currency: 'str', ordered_quantity: 'Decimal', filled_quantity: 'Decimal', unfilled_quantity: 'Decimal', decision_price: 'Decimal', arrival_price: 'Decimal | None', end_price: 'Decimal | None', average_fill_price: 'Decimal | None', paper_notional: 'Decimal', execution_cost: 'Decimal', delay_cost: 'Decimal | None', trading_cost: 'Decimal | None', explicit_costs: 'Decimal', opportunity_cost: 'Decimal | None', total: 'Decimal | None', total_bps: 'Decimal | None', strategy_shares: 'tuple[tuple[str, Decimal], ...]', execution_ids: 'tuple[str, ...]', venues: 'tuple[str, ...]', assumptions: 'tuple[str, ...]', unavailable: 'Mapping[str, str]') -> None", + "IncompletePolicy": "enum alphalab.execution.algorithms.IncompletePolicy[LEAVE_UNFILLED, COMPLETE_AT_END]", + "IntervalVolume": "class alphalab.execution.algorithms.IntervalVolume(start: 'float', end: 'float', volume: 'Decimal | None') -> None", + "LatencyMeasurement": "class alphalab.execution.quality.LatencyMeasurement(order_id: 'str', venue: 'str', start: 'LifecycleMark', end: 'LifecycleMark', seconds: 'Decimal | None', start_clock: 'ClockSource | None', end_clock: 'ClockSource | None', mixed_clocks: 'bool', reason: 'str') -> None", + "LatencyModel": "class alphalab.execution.latency.LatencyModel(*args, **kwargs)", + "LifecycleMark": "enum alphalab.execution.quality.LifecycleMark[DECISION, SUBMISSION, ACKNOWLEDGEMENT, FIRST_FILL, LAST_FILL, TERMINAL]", + "LinearImpact": "class alphalab.execution.costs.LinearImpact(coefficient: 'Decimal') -> None", + "LiquidityCappedFill": "class alphalab.execution.policy.LiquidityCappedFill(participation_rate: decimal.Decimal = Decimal('1')) -> None", + "LiquidityContext": "class alphalab.execution.policy.LiquidityContext(asset_id: str, side: alphalab.core.enums.Side, requested_quantity: decimal.Decimal, price: decimal.Decimal, available_quantity: decimal.Decimal | None, timestamp: float) -> None", + "MAX_SPLIT_FIXED_CHARGE_VENUES": "value builtins.int", + "MAX_URGENCY": "value decimal.Decimal", + "MarketImpactSlippage": "class alphalab.execution.slippage.MarketImpactSlippage(impact_factor: decimal.Decimal, reference_quantity: decimal.Decimal) -> None", + "MissingVolumePolicy": "enum alphalab.execution.algorithms.MissingVolumePolicy[REFUSE, TIME_WEIGHTED]", + "NoFee": "class alphalab.execution.costs.NoFee() -> None", + "NoImpact": "class alphalab.execution.costs.NoImpact() -> None", + "NoSlippage": "class alphalab.execution.costs.NoSlippage() -> None", + "NoSpread": "class alphalab.execution.costs.NoSpread() -> None", + "NoTax": "class alphalab.execution.costs.NoTax() -> None", + "OrderExecution": "class alphalab.execution.quality.OrderExecution(order: 'OrderRequest', fills: 'tuple[ExecutionReport, ...]', currency: 'str', outcome: 'OrderStatus', benchmarks: 'ExecutionBenchmarks', limit_price: 'Decimal | None', timeline: 'OrderTimeline | None', fill_midpoints: 'Mapping[str, Decimal] | None', account_id: 'str | None') -> None", + "OrderInstruction": "class alphalab.execution.fill.OrderInstruction(order_id: str, strategy_id: str, asset_id: str, quantity: decimal.Decimal, price: decimal.Decimal, side: alphalab.core.enums.Side, venue: str, currency: str, minor_units: int | None = None) -> None", + "OrderOutcome": "class alphalab.execution.quality.OrderOutcome(order_id: 'str', venue: 'str', submitted_at: 'float', status: 'OrderStatus') -> None", + "OrderTimeline": "class alphalab.execution.quality.OrderTimeline(order_id: 'str', venue: 'str', marks: 'tuple[TimelineMark, ...]') -> None", + "Participation": "class alphalab.execution.algorithms.Participation(rate: 'Decimal', min_child_quantity: 'Decimal | None', max_child_quantity: 'Decimal | None', at_end: 'IncompletePolicy') -> None", + "PerShareCommission": "class alphalab.execution.commission.PerShareCommission(rate_per_share: decimal.Decimal) -> None", + "PerTradeFee": "class alphalab.execution.costs.PerTradeFee(amount: 'Decimal') -> None", + "PercentageCommission": "class alphalab.execution.commission.PercentageCommission(rate: decimal.Decimal) -> None", + "PercentageSlippage": "class alphalab.execution.slippage.PercentageSlippage(rate: decimal.Decimal) -> None", + "PriceTimeQueue": "class alphalab.execution.queue.PriceTimeQueue(count_unsigned_trades: 'bool' = False) -> None", + "ProportionalFee": "class alphalab.execution.costs.ProportionalFee(fraction: 'Decimal') -> None", + "ProportionalTax": "class alphalab.execution.costs.ProportionalTax(fraction: 'Decimal', sides: 'frozenset[Side]') -> None", + "QueueBook": "class alphalab.execution.queue.QueueBook(model: 'QueueModel', positions: 'PersistentMap[str, QueuePosition]' = ) -> None", + "QueueModel": "class alphalab.execution.queue.QueueModel(*args, **kwargs)", + "QueueObservation": "class alphalab.execution.queue.QueueObservation(timestamp: 'float', bid: 'Decimal | None' = None, bid_size: 'Decimal | None' = None, ask: 'Decimal | None' = None, ask_size: 'Decimal | None' = None, trade_price: 'Decimal | None' = None, trade_quantity: 'Decimal | None' = None, aggressor: 'Side | None' = None, low: 'Decimal | None' = None, high: 'Decimal | None' = None) -> None", + "QueuePosition": "class alphalab.execution.queue.QueuePosition(limit_price: 'Decimal', ahead: 'Decimal | None') -> None", + "QueueTurn": "class alphalab.execution.queue.QueueTurn(position: 'QueuePosition', eligible: 'Decimal | None') -> None", + "QuotedHalfSpread": "class alphalab.execution.costs.QuotedHalfSpread() -> None", + "ROUTE_DECISION_SCHEME": "value builtins.str", + "ROUTING_POLICY_SCHEME": "value builtins.str", + "ReferencePrice": "enum alphalab.execution.quality.ReferencePrice[DECISION, ARRIVAL, INTERVAL_VWAP]", + "RejectionRate": "class alphalab.execution.quality.RejectionRate(window_start: 'float', window_end: 'float', venue: 'str | None', submitted: 'int', resolved: 'int', rejected: 'int', unresolved: 'int', rate: 'Decimal | None') -> None", + "RouteCandidate": "class alphalab.execution.routing.RouteCandidate(venue: 'str', status: 'CandidateStatus', reason: 'str', quoted_price: 'Decimal | None', displayed_quantity: 'Decimal | None', executable_quantity: 'Decimal', expected_price: 'Decimal | None', all_in_price: 'Decimal | None', expected_costs: 'ExecutionCosts | None', latency_seconds: 'Decimal | None', compatibility: 'CompatibilityReport | None', rank: 'int | None') -> None", + "RouteDecision": "class alphalab.execution.routing.RouteDecision(request: 'RouteRequest', policy: 'RoutingPolicy', status: 'RouteStatus', legs: 'tuple[RouteLeg, ...]', candidates: 'tuple[RouteCandidate, ...]', reason: 'str', evidence_id: 'str') -> None", + "RouteLeg": "class alphalab.execution.routing.RouteLeg(venue: 'str', quantity: 'Decimal', quoted_price: 'Decimal', expected_price: 'Decimal', expected_costs: 'ExecutionCosts', all_in_price: 'Decimal') -> None", + "RouteRequest": "class alphalab.execution.routing.RouteRequest(order_id: 'str', asset_id: 'str', side: 'Side', quantity: 'Decimal', limit_price: 'Decimal | None', currency: 'str', requirements: 'ExecutionRequirements', quantity_increment: 'Decimal', decided_at: 'float') -> None", + "RouteStatus": "enum alphalab.execution.routing.RouteStatus[ROUTED, PARTIAL, INFEASIBLE, INSUFFICIENT_EVIDENCE]", + "RoutingObjective": "enum alphalab.execution.routing.RoutingObjective[BEST_QUOTED_PRICE, LOWEST_ALL_IN_COST]", + "RoutingPolicy": "class alphalab.execution.routing.RoutingPolicy(objective: 'RoutingObjective', max_quote_age_seconds: 'float', max_latency_seconds: 'Decimal | None', allow_split: 'bool', allow_partial: 'bool', excluded_venues: 'frozenset[str]', split_method: 'SplitMethod' = ) -> None", + "ScheduleBasis": "enum alphalab.execution.algorithms.ScheduleBasis[TIME, VOLUME, TIME_FALLBACK]", + "ScheduleCost": "class alphalab.execution.algorithms.ScheduleCost(quantity: 'Decimal', trades: 'tuple[Decimal, ...]', interval: 'Decimal', permanent: 'Decimal', fixed: 'Decimal', temporary: 'Decimal', expected: 'Decimal', variance: 'Decimal', standard_deviation: 'Decimal', volatility: 'Decimal', temporary_impact: 'Decimal', permanent_impact: 'Decimal', fixed_cost: 'Decimal', horizon: 'Decimal') -> None", + "ScheduledSlice": "class alphalab.execution.algorithms.ScheduledSlice(index: 'int', start: 'float', end: 'float', weight: 'Decimal', quantity: 'Decimal', target: 'Decimal') -> None", + "ShortfallAgainstModel": "class alphalab.execution.quality.ShortfallAgainstModel(order_id: 'str', currency: 'str', quantity: 'Decimal', realized: 'Decimal', expected: 'Decimal', standard_deviation: 'Decimal', difference: 'Decimal', standard_deviations: 'Decimal | None') -> None", + "Slicing": "class alphalab.execution.algorithms.Slicing(slice_quantity: 'Decimal | None', slice_count: 'int | None') -> None", + "SlippageMeasurement": "class alphalab.execution.quality.SlippageMeasurement(order_id: 'str', reference: 'ReferencePrice', reference_price: 'Decimal | None', average_fill_price: 'Decimal | None', filled_quantity: 'Decimal', currency: 'str', per_unit: 'Decimal | None', amount: 'Decimal | None', bps: 'Decimal | None', reason: 'str') -> None", + "SlippageModel": "class alphalab.execution.slippage.SlippageModel(*args, **kwargs)", + "SplitMethod": "enum alphalab.execution.routing.SplitMethod[GREEDY_SWEEP, OPTIMAL]", + "SpreadModel": "class alphalab.execution.costs.SpreadModel(*args, **kwargs)", + "SquareRootImpact": "class alphalab.execution.costs.SquareRootImpact(coefficient: 'Decimal') -> None", + "StaticFill": "class alphalab.execution.policy.StaticFill(status: alphalab.execution.fill.FillStatus = , quantity: decimal.Decimal | None = None) -> None", + "TWAP": "class alphalab.execution.algorithms.TWAP(slices: 'int', urgency: 'Urgency') -> None", + "TaxModel": "class alphalab.execution.costs.TaxModel(*args, **kwargs)", + "TimelineMark": "class alphalab.execution.quality.TimelineMark(mark: 'LifecycleMark', at: 'float', clock: 'ClockSource') -> None", + "TrancheRandomization": "class alphalab.execution.algorithms.TrancheRandomization(spread: 'Decimal', seed: 'int') -> None", + "Urgency": "class alphalab.execution.algorithms.Urgency(kappa: 'Decimal') -> None", + "UrgencyEstimate": "class alphalab.execution.algorithms.UrgencyEstimate(risk_aversion: 'Decimal', volatility: 'Decimal', temporary_impact: 'Decimal', permanent_impact: 'Decimal', horizon: 'Decimal', slices: 'int', kappa: 'Decimal', urgency: 'Urgency') -> None", + "VWAP": "class alphalab.execution.algorithms.VWAP(profile: 'VolumeProfile', missing_volume: 'MissingVolumePolicy', urgency: 'Urgency') -> None", + "VenueProfile": "class alphalab.execution.routing.VenueProfile(venue: 'str', capabilities: 'CapabilityDeclaration', costs: 'ExecutionCostModel', latency_seconds: 'Decimal | None') -> None", + "VenueQuality": "class alphalab.execution.quality.VenueQuality(venue: 'str', currency: 'str | None', fills: 'int', filled_quantity: 'Decimal', slippage_reference: 'ReferencePrice', slippage_bps: 'Decimal | None', unmeasured_fills: 'int', rejection_rate: 'RejectionRate', acknowledgement_seconds_median: 'Decimal | None') -> None", + "VenueQuote": "class alphalab.execution.routing.VenueQuote(venue: 'str', asset_id: 'str', bid: 'Decimal | None', ask: 'Decimal | None', bid_size: 'Decimal | None', ask_size: 'Decimal | None', currency: 'str', as_of: 'float', source: 'str') -> None", + "VolumeProfile": "class alphalab.execution.algorithms.VolumeProfile(intervals: 'tuple[IntervalVolume, ...]', source: 'str') -> None", + "algorithm_configuration_id": "function alphalab.execution.algorithms.algorithm_configuration_id(algorithm: 'ExecutionAlgorithm') -> 'str'", + "all_reports": "function alphalab.execution.views.all_reports(state: alphalab.execution.state.ExecutionState) -> collections.abc.Sequence[alphalab.execution.report.ExecutionReport]", + "cancel_algorithm": "function alphalab.execution.algorithms.cancel_algorithm(state: 'AlgorithmState', now: 'float', reason: 'str') -> 'tuple[AlgorithmState, tuple[ChildOrder, ...]]'", + "capacity_curve": "function alphalab.execution.capacity.capacity_curve(model: 'CapacityModel', universe: 'Sequence[AssetLiquidity]', capital_levels: 'Sequence[Decimal]', timestamp: 'float') -> 'tuple[tuple[Decimal, Decimal, Decimal], ...]'", + "estimate_urgency": "function alphalab.execution.algorithms.estimate_urgency(*, risk_aversion: 'Decimal', volatility: 'Decimal', temporary_impact: 'Decimal', permanent_impact: 'Decimal', horizon: 'Decimal', slices: 'int') -> 'UrgencyEstimate'", + "execution_quality_report": "function alphalab.execution.quality.execution_quality_report(executions: 'Sequence[OrderExecution]', *, reporting_currency: 'str | None', converter: 'CurrencyConverter | None', conversion_at: 'float | None') -> 'ExecutionQualityReport'", + "fill_quality": "function alphalab.execution.quality.fill_quality(execution: 'OrderExecution') -> 'FillQuality'", + "implementation_shortfall": "function alphalab.execution.quality.implementation_shortfall(execution: 'OrderExecution') -> 'ImplementationShortfall'", + "itemized": "function alphalab.execution.costs.itemized(costs: 'ExecutionCosts', quantity: 'Decimal') -> 'Mapping[str, Decimal]'", + "measure_latency": "function alphalab.execution.quality.measure_latency(timeline: 'OrderTimeline', start: 'LifecycleMark', end: 'LifecycleMark') -> 'LatencyMeasurement'", + "measure_slippage": "function alphalab.execution.quality.measure_slippage(execution: 'OrderExecution', reference: 'ReferencePrice') -> 'SlippageMeasurement'", + "plan_schedule": "function alphalab.execution.algorithms.plan_schedule(parent_quantity: 'Decimal', algorithm: 'TWAP | VWAP', terms: 'AlgorithmTerms') -> 'ExecutionSchedule'", + "reconciles": "function alphalab.execution.costs.reconciles(costs: 'ExecutionCosts', concession: 'Decimal', cash: 'Decimal') -> 'bool'", + "record_child_execution": "function alphalab.execution.algorithms.record_child_execution(state: 'AlgorithmState', child_id: 'str', execution_id: 'str', quantity: 'Decimal', timestamp: 'float') -> 'AlgorithmState'", + "record_child_outcome": "function alphalab.execution.algorithms.record_child_outcome(state: 'AlgorithmState', child_id: 'str', outcome: 'OrderStatus', timestamp: 'float') -> 'AlgorithmState'", + "rejection_rate": "function alphalab.execution.quality.rejection_rate(outcomes: 'Sequence[OrderOutcome]', *, window_start: 'float', window_end: 'float', venue: 'str | None') -> 'RejectionRate'", + "release_children": "function alphalab.execution.algorithms.release_children(state: 'AlgorithmState', now: 'float', volumes: 'Sequence[IntervalVolume]' = ()) -> 'tuple[AlgorithmState, tuple[ChildOrder, ...]]'", + "report": "function alphalab.execution.views.report(state: alphalab.execution.state.ExecutionState, execution_id: str) -> alphalab.execution.report.ExecutionReport | None", + "reports_for_asset": "function alphalab.execution.views.reports_for_asset(state: alphalab.execution.state.ExecutionState, asset_id: str) -> collections.abc.Sequence[alphalab.execution.report.ExecutionReport]", + "reports_for_order": "function alphalab.execution.views.reports_for_order(state: alphalab.execution.state.ExecutionState, order_id: str) -> collections.abc.Sequence[alphalab.execution.report.ExecutionReport]", + "schedule_cost": "function alphalab.execution.algorithms.schedule_cost(trades: 'Sequence[Decimal]', *, volatility: 'Decimal', temporary_impact: 'Decimal', permanent_impact: 'Decimal', fixed_cost: 'Decimal', horizon: 'Decimal') -> 'ScheduleCost'", + "select_route": "function alphalab.execution.routing.select_route(request: 'RouteRequest', quotes: 'Sequence[VenueQuote]', profiles: 'Sequence[VenueProfile]', policy: 'RoutingPolicy') -> 'RouteDecision'", + "shortfall_against_model": "function alphalab.execution.quality.shortfall_against_model(shortfall: 'ImplementationShortfall', model: 'ScheduleCost') -> 'ShortfallAgainstModel'", + "start_algorithm": "function alphalab.execution.algorithms.start_algorithm(parent: 'OrderRequest', algorithm: 'ExecutionAlgorithm', terms: 'AlgorithmTerms') -> 'AlgorithmState'", + "validate_execution_parameters": "function alphalab.execution.validation.validate_execution_parameters(quantity: decimal.Decimal, price: decimal.Decimal, commission: decimal.Decimal, timestamp: float) -> None", + "venue_quality": "function alphalab.execution.quality.venue_quality(executions: 'Sequence[OrderExecution]', outcomes: 'Sequence[OrderOutcome]', *, reference: 'ReferencePrice', window_start: 'float', window_end: 'float') -> 'tuple[VenueQuality, ...]'" + }, + "alphalab.experiment_tracking": { + "ExperimentRun": "class alphalab.experiment_tracking.tracker.ExperimentRun(run_id: str, name: str, status: alphalab.experiment_tracking.tracker.RunStatus, parameters: collections.abc.Mapping[str, ParamValue], metrics: alphalab.common.persistent_map.PersistentMap[str, alphalab.common.append_log.AppendOnlyLog[float]] = , parent_run_id: str | None = None, tags: collections.abc.Mapping[str, str] = , created_at: float = 0.0, completed_at: float | None = None) -> None", + "ExperimentTracker": "class alphalab.experiment_tracking.tracker.ExperimentTracker(runs: alphalab.common.persistent_map.PersistentMap[str, alphalab.experiment_tracking.tracker.ExperimentRun] = ) -> None", + "ExperimentTrackingError": "class alphalab.experiment_tracking.exceptions.ExperimentTrackingError", + "ExperimentTrackingInputError": "class alphalab.experiment_tracking.exceptions.ExperimentTrackingInputError", + "ParamValue": "alias ParamValue", + "RunStatus": "enum alphalab.experiment_tracking.tracker.RunStatus[RUNNING, COMPLETED, FAILED]", + "best_metric_value": "function alphalab.experiment_tracking.comparison.best_metric_value(run: alphalab.experiment_tracking.tracker.ExperimentRun, metric_name: str, higher_is_better: bool = True) -> float | None", + "best_run": "function alphalab.experiment_tracking.comparison.best_run(tracker: alphalab.experiment_tracking.tracker.ExperimentTracker, metric_name: str, higher_is_better: bool = True, use_best_value: bool = False) -> alphalab.experiment_tracking.tracker.ExperimentRun | None", + "compare_runs": "function alphalab.experiment_tracking.comparison.compare_runs(tracker: alphalab.experiment_tracking.tracker.ExperimentTracker, run_ids: tuple[str, ...], metric_name: str) -> collections.abc.Mapping[str, float | None]", + "complete_run": "function alphalab.experiment_tracking.tracker.complete_run(tracker: alphalab.experiment_tracking.tracker.ExperimentTracker, run_id: str, timestamp: float) -> alphalab.experiment_tracking.tracker.ExperimentTracker", + "fail_run": "function alphalab.experiment_tracking.tracker.fail_run(tracker: alphalab.experiment_tracking.tracker.ExperimentTracker, run_id: str, timestamp: float) -> alphalab.experiment_tracking.tracker.ExperimentTracker", + "latest_metric_value": "function alphalab.experiment_tracking.comparison.latest_metric_value(run: alphalab.experiment_tracking.tracker.ExperimentRun, metric_name: str) -> float | None", + "lineage": "function alphalab.experiment_tracking.versioning.lineage(tracker: alphalab.experiment_tracking.tracker.ExperimentTracker, run_id: str) -> tuple[alphalab.experiment_tracking.tracker.ExperimentRun, ...]", + "log_metric": "function alphalab.experiment_tracking.tracker.log_metric(tracker: alphalab.experiment_tracking.tracker.ExperimentTracker, run_id: str, metric_name: str, value: float) -> alphalab.experiment_tracking.tracker.ExperimentTracker", + "log_metrics": "function alphalab.experiment_tracking.tracker.log_metrics(tracker: alphalab.experiment_tracking.tracker.ExperimentTracker, run_id: str, metrics: collections.abc.Mapping[str, float]) -> alphalab.experiment_tracking.tracker.ExperimentTracker", + "new_version": "function alphalab.experiment_tracking.versioning.new_version(tracker: alphalab.experiment_tracking.tracker.ExperimentTracker, parent_run_id: str, parameters: collections.abc.Mapping[str, ParamValue], timestamp: float) -> tuple[alphalab.experiment_tracking.tracker.ExperimentTracker, str]", + "start_run": "function alphalab.experiment_tracking.tracker.start_run(tracker: alphalab.experiment_tracking.tracker.ExperimentTracker, name: str, parameters: collections.abc.Mapping[str, ParamValue], timestamp: float, parent_run_id: str | None = None, tags: collections.abc.Mapping[str, str] | None = None) -> tuple[alphalab.experiment_tracking.tracker.ExperimentTracker, str]", + "version_number": "function alphalab.experiment_tracking.versioning.version_number(tracker: alphalab.experiment_tracking.tracker.ExperimentTracker, run_id: str) -> int" + }, + "alphalab.factor_library": { + "Applicability": "class alphalab.factor_library.applicability.Applicability(verdict: 'Verdict', reason: 'str') -> None", + "DELISTING_SET_SCHEME": "value builtins.str", + "DecayProfile": "class alphalab.factor_library.decay.DecayProfile(factor_lineage: 'str', dataset_version: 'str | None', horizons: 'Mapping[int, InformationCoefficient]') -> None", + "DelistingReturn": "class alphalab.factor_library.forward_returns.DelistingReturn(symbol: 'str', timestamp: 'float', terminal_return: 'float') -> None", + "ExposureReport": "class alphalab.factor_library.exposure.ExposureReport(instants: 'int', by_asset: 'Mapping[str, float]', by_group: 'Mapping[str, float]', gross_by_group: 'Mapping[str, float]', gross_exposure: 'float', net_exposure: 'float', largest_group: 'str | None', concentration: 'float') -> None", + "FACTOR_CATALOG": "value builtins.tuple", + "FEATURE_KEY_SCHEME": "value builtins.str", + "FEATURE_SERIES_KEY_SCHEME": "value builtins.str", + "FIELD_AVAILABILITY": "value builtins.dict", + "FIELD_READERS": "value builtins.dict", + "FactorCategory": "enum alphalab.factor_library.catalog.FactorCategory[MOMENTUM, VALUE, QUALITY, CARRY, VOLATILITY, LIQUIDITY]", + "FactorComputationError": "class alphalab.factor_library.exceptions.FactorComputationError", + "FactorInputError": "class alphalab.factor_library.exceptions.FactorInputError", + "FactorLibraryError": "class alphalab.factor_library.exceptions.FactorLibraryError", + "FactorRanking": "class alphalab.factor_library.ranking.FactorRanking(panel: 'FeaturePanel', counts: 'Mapping[float, int]', method: 'RankMethod') -> None", + "FactorResult": "class alphalab.factor_library.result.FactorResult(feature_id: str, version: int, asset_id: str | None, value: float, timestamp: float) -> None", + "FactorSpec": "class alphalab.factor_library.catalog.FactorSpec(factor_id: str, category: alphalab.factor_library.catalog.FactorCategory, required_input: alphalab.factor_library.catalog.RequiredInput, description: str) -> None", + "FactorTransform": "class alphalab.factor_library.panel.FactorTransform(operation: 'str', detail: 'str' = '') -> None", + "FactorTurnover": "class alphalab.factor_library.turnover.FactorTurnover(convention: 'TurnoverConvention', mean_turnover: 'float', per_period: 'Mapping[float, float]', periods_measured: 'int', entries: 'int', exits: 'int') -> None", + "FeatureDefinition": "class alphalab.factor_library.definition.FeatureDefinition(feature_id: 'str', kind: 'FeatureKind', source_field: 'FeatureField', window: 'int | None' = None, parameters: 'Mapping[str, float]' = , missing: 'MissingPolicy' = , scope: 'FeatureScope' = ) -> None", + "FeatureField": "enum alphalab.factor_library.definition.FeatureField[OPEN, HIGH, LOW, CLOSE, VOLUME, PRICE, SIZE, BID, ASK, MID, OBSERVATION]", + "FeatureKind": "enum alphalab.factor_library.definition.FeatureKind[RETURN, LOG_RETURN, ROLLING_MEAN, ROLLING_STD, ROLLING_MIN, ROLLING_MAX, ROLLING_SUM, EXPONENTIAL_MEAN, REALIZED_VOLATILITY, MOMENTUM, MEAN_REVERSION, ROLLING_ZSCORE, ROLLING_RATIO, VOLATILITY_REGIME, TIME_OF_DAY, DAY_OF_WEEK, CROSS_SECTIONAL_RANK, CROSS_SECTIONAL_ZSCORE, CROSS_SECTIONAL_DEMEAN]", + "FeaturePanel": "class alphalab.factor_library.panel.FeaturePanel(definition: 'FeatureDefinition', dataset_version: 'str | None', timezone_name: 'str', rows: 'Mapping[float, Mapping[str, float]]', symbols: 'tuple[str, ...]', transforms: 'tuple[FactorTransform, ...]' = ()) -> None", + "FeatureScope": "enum alphalab.factor_library.definition.FeatureScope[TIME_SERIES, CROSS_SECTIONAL]", + "FeatureSeries": "class alphalab.factor_library.series.FeatureSeries(definition: 'FeatureDefinition', symbol: 'str', timestamps: 'tuple[float, ...]', values: 'tuple[float, ...]', dataset_version: 'str | None', timezone_name: 'str', observations: 'int', warmup_periods: 'int') -> None", + "ForwardReturnPanel": "class alphalab.factor_library.forward_returns.ForwardReturnPanel(horizon: 'int', rows: 'Mapping[float, Mapping[str, float]]', dataset_version: 'str | None', timezone_name: 'str', symbols: 'tuple[str, ...]', unrealized_instants: 'int', lag: 'int', delisted_instants: 'int', unenterable_instants: 'int', delistings: 'tuple[DelistingReturn, ...]') -> None", + "FrameRatio": "class alphalab.factor_library.knowledge.FrameRatio(frame: 'ObservationFrame', undefined: 'int') -> None", + "FundamentalSnapshot": "class alphalab.factor_library.inputs.FundamentalSnapshot(asset_id: str, timestamp: float, price: decimal.Decimal, earnings_per_share: decimal.Decimal, book_value_per_share: decimal.Decimal, dividend_per_share: decimal.Decimal) -> None", + "InformationCoefficient": "class alphalab.factor_library.ic.InformationCoefficient(horizon: 'int', factor_lineage: 'str', dataset_version: 'str | None', instants_measured: 'int', instants_skipped: 'int', observations: 'int', minimum_assets: 'int', mean_pearson: 'float | None', mean_rank: 'float | None', hit_rate: 'float | None', per_instant_rank: 'tuple[tuple[float, float], ...]', lag: 'int', newey_west_lag: 'int', rank_standard_error: 'float | None', rank_t_statistic: 'float | None') -> None", + "KIND_REQUIREMENTS": "value builtins.mappingproxy", + "KNOWLEDGE_FRAME_SCHEME": "value builtins.str", + "KindRequirement": "class alphalab.factor_library.definition.KindRequirement(scope: 'FeatureScope', needs_window: 'bool', minimum_window: 'int' = 1, required_parameters: 'tuple[str, ...]' = (), permitted_parameters: 'tuple[str, ...]' = (), window_offset: 'int' = 0, reads_calendar: 'bool' = False) -> None", + "KnowledgeFrame": "class alphalab.factor_library.knowledge.KnowledgeFrame(frame: 'ObservationFrame', frame_id: 'str', set_version: 'str', selection: 'str', clock: 'ResearchClock', visibility: 'VisibilityRule', policy: 'VintagePolicy', max_age_seconds: 'float | None', sources: 'Mapping[str, tuple[tuple[str, ...], ...]]', unknown: 'int', stale: 'int', unverified: 'int') -> None", + "KnowledgeStep": "class alphalab.factor_library.knowledge.KnowledgeStep(known_at: 'float', value: 'float | None', observed_at: 'float | None', record_ids: 'tuple[str, ...]') -> None", + "MAXIMUM_EXPOSURE_CONDITION": "value builtins.float", + "MULTIPLICATIVE_CLASSES": "value builtins.frozenset", + "MissingPolicy": "enum alphalab.factor_library.definition.MissingPolicy[REFUSE, SKIP]", + "NeutralizationReport": "class alphalab.factor_library.neutralization.NeutralizationReport(panel: 'FeaturePanel', instants_neutralized: 'int', instants_skipped: 'int', mean_absolute_removed: 'float', refusals: 'tuple[tuple[float, str], ...]' = ()) -> None", + "ObservationFrame": "class alphalab.factor_library.observations.ObservationFrame(source_field: 'FeatureField', series: 'Mapping[str, ObservationSeries]', timezone_name: 'str', dataset_version: 'str | None' = None) -> None", + "ObservationSeries": "class alphalab.factor_library.observations.ObservationSeries(symbol: 'str', timestamps: 'tuple[float, ...]', values: 'tuple[float, ...]') -> None", + "PriceSeries": "class alphalab.factor_library.inputs.PriceSeries(asset_id: str, bars: tuple[alphalab.market.bar.Bar, ...]) -> None", + "RATIO_KINDS": "value builtins.frozenset", + "RequiredInput": "enum alphalab.factor_library.catalog.RequiredInput[PRICE_SERIES, FUNDAMENTAL_SNAPSHOT]", + "ResearchClock": "class alphalab.factor_library.knowledge.ResearchClock(instants: 'tuple[float, ...]', timezone_name: 'str', dataset_version: 'str | None') -> None", + "SnapshotSpecification": "class alphalab.factor_library.fundamentals.SnapshotSpecification(earnings_per_share: 'FundamentalInput', book_equity: 'FundamentalInput', shares_outstanding: 'FundamentalInput', dividends_per_share: 'FundamentalInput') -> None", + "TurnoverConvention": "enum alphalab.factor_library.turnover.TurnoverConvention[ABSOLUTE_CHANGE, ONE_WAY]", + "Verdict": "enum alphalab.factor_library.applicability.Verdict[APPLIES, NO_SUCH_FIELD, NOT_RATIO_SCALED]", + "align_prices": "function alphalab.factor_library.knowledge.align_prices(prices: 'ObservationFrame', knowledge: 'KnowledgeFrame') -> 'ObservationFrame'", + "bucket_panel": "function alphalab.factor_library.ranking.bucket_panel(panel: 'FeaturePanel', buckets: 'int', tie_break: 'TieBreak' = ) -> 'FeaturePanel'", + "canonical_feature_key": "function alphalab.factor_library.definition.canonical_feature_key(definition: 'FeatureDefinition') -> 'str'", + "canonical_series_key": "function alphalab.factor_library.series.canonical_series_key(feature_version: 'str', dataset_version: 'str', symbol: 'str', timezone_name: 'str') -> 'str'", + "compute_carry": "function alphalab.factor_library.carry.compute_carry(fundamentals: alphalab.factor_library.inputs.FundamentalSnapshot, feature_id: str, version: int, timestamp: float) -> alphalab.factor_library.result.FactorResult", + "compute_cross_section": "function alphalab.factor_library.primitives.compute_cross_section(definition: 'FeatureDefinition', values: 'Mapping[str, float]') -> 'Mapping[str, float]'", + "compute_feature": "function alphalab.factor_library.compute.compute_feature(definition: 'FeatureDefinition', frame: 'ObservationFrame') -> 'tuple[FeatureSeries, ...]'", + "compute_features": "function alphalab.factor_library.compute.compute_features(definitions: 'Sequence[FeatureDefinition]', frame: 'ObservationFrame') -> 'dict[str, tuple[FeatureSeries, ...]]'", + "compute_liquidity": "function alphalab.factor_library.liquidity.compute_liquidity(prices: alphalab.factor_library.inputs.PriceSeries, feature_id: str, version: int, lookback_periods: int, timestamp: float) -> alphalab.factor_library.result.FactorResult", + "compute_momentum": "function alphalab.factor_library.momentum.compute_momentum(prices: alphalab.factor_library.inputs.PriceSeries, feature_id: str, version: int, lookback_periods: int, timestamp: float) -> alphalab.factor_library.result.FactorResult", + "compute_panel": "function alphalab.factor_library.compute.compute_panel(definition: 'FeatureDefinition', frame: 'ObservationFrame') -> 'FeaturePanel'", + "compute_quality": "function alphalab.factor_library.quality.compute_quality(fundamentals: alphalab.factor_library.inputs.FundamentalSnapshot, feature_id: str, version: int, timestamp: float) -> alphalab.factor_library.result.FactorResult", + "compute_time_series": "function alphalab.factor_library.primitives.compute_time_series(definition: 'FeatureDefinition', values: 'Sequence[float]', timestamps: 'Sequence[float]', timezone_name: 'str') -> 'tuple[float | None, ...]'", + "compute_value": "function alphalab.factor_library.value.compute_value(fundamentals: alphalab.factor_library.inputs.FundamentalSnapshot, feature_id: str, version: int, timestamp: float) -> alphalab.factor_library.result.FactorResult", + "compute_volatility": "function alphalab.factor_library.volatility.compute_volatility(prices: alphalab.factor_library.inputs.PriceSeries, feature_id: str, version: int, lookback_periods: int, timestamp: float, periods_per_year: int) -> alphalab.factor_library.result.FactorResult", + "delisting_set_id": "function alphalab.factor_library.forward_returns.delisting_set_id(delistings: 'Sequence[DelistingReturn]') -> 'str'", + "derive_feature_version": "function alphalab.factor_library.definition.derive_feature_version(definition: 'FeatureDefinition') -> 'str'", + "derive_frame_id": "function alphalab.factor_library.knowledge.derive_frame_id(kind: 'str', set_version: 'str', selection: 'str', visibility: 'VisibilityRule', policy: 'VintagePolicy', max_age_seconds: 'float | None', clock: 'ResearchClock', subjects: 'Sequence[str]') -> 'str'", + "derive_series_id": "function alphalab.factor_library.series.derive_series_id(feature_version: 'str', dataset_version: 'str', symbol: 'str', timezone_name: 'str') -> 'str'", + "divide_frames": "function alphalab.factor_library.knowledge.divide_frames(numerator: 'ObservationFrame', denominator: 'ObservationFrame', *, name: 'str') -> 'FrameRatio'", + "event_frame": "function alphalab.factor_library.knowledge.event_frame(view: 'ObservationView[InformationEvent]', event_type: 'str', measurement: 'str', clock: 'ResearchClock', subjects: 'Sequence[str]', *, max_age_seconds: 'float | None', policy: 'VintagePolicy') -> 'KnowledgeFrame'", + "factor_decay": "function alphalab.factor_library.decay.factor_decay(factor: 'FeaturePanel', prices: 'ObservationFrame', horizons: 'Sequence[int]', minimum_assets: 'int' = 5, *, lag: 'int', delistings: 'Sequence[DelistingReturn]') -> 'DecayProfile'", + "factor_exposure": "function alphalab.factor_library.exposure.factor_exposure(weights: 'Mapping[float, Mapping[str, float]]', groups: 'Mapping[str, str] | None' = None) -> 'ExposureReport'", + "factor_turnover": "function alphalab.factor_library.turnover.factor_turnover(weights: 'Mapping[float, Mapping[str, float]]', convention: 'TurnoverConvention' = ) -> 'FactorTurnover'", + "feature_applicability": "function alphalab.factor_library.applicability.feature_applicability(definition: 'FeatureDefinition', asset_class: 'DataAssetClass') -> 'Applicability'", + "forward_returns": "function alphalab.factor_library.forward_returns.forward_returns(frame: 'ObservationFrame', horizon: 'int', *, lag: 'int', delistings: 'Sequence[DelistingReturn]') -> 'ForwardReturnPanel'", + "fundamental_frame": "function alphalab.factor_library.fundamentals.fundamental_frame(view: 'ObservationView[FundamentalObservation]', spec: 'FundamentalInput', clock: 'ResearchClock', subjects: 'Sequence[str]', *, max_age_seconds: 'float | None', policy: 'VintagePolicy') -> 'KnowledgeFrame'", + "fundamental_snapshot_as_of": "function alphalab.factor_library.fundamentals.fundamental_snapshot_as_of(view: 'ObservationView[FundamentalObservation]', subject: 'str', as_of: 'float', price: 'Decimal', price_unit: 'str', price_observed_at: 'float', policy: 'VintagePolicy', spec: 'SnapshotSpecification') -> 'FundamentalSnapshot'", + "get_spec": "function alphalab.factor_library.catalog.get_spec(factor_id: str) -> alphalab.factor_library.catalog.FactorSpec | None", + "information_coefficient": "function alphalab.factor_library.ic.information_coefficient(factor: 'FeaturePanel', returns: 'ForwardReturnPanel', minimum_assets: 'int' = 5) -> 'InformationCoefficient'", + "loadings_from_panels": "function alphalab.factor_library.loadings.loadings_from_panels(panels: 'Mapping[str, FeaturePanel]', *, instant: 'float', source: 'str', asset_ids: 'Mapping[str, str] | None') -> 'FactorLoadings'", + "neutralize_beta": "function alphalab.factor_library.neutralization.neutralize_beta(panel: 'FeaturePanel', exposures: 'Mapping[float, Mapping[str, float]]', minimum_assets: 'int' = 3) -> 'NeutralizationReport'", + "neutralize_exposures": "function alphalab.factor_library.neutralization.neutralize_exposures(panel: 'FeaturePanel', exposures: 'Mapping[str, Mapping[float, Mapping[str, float]]]', *, minimum_assets: 'int', maximum_condition: 'float' = 1000000.0) -> 'NeutralizationReport'", + "neutralize_group": "function alphalab.factor_library.neutralization.neutralize_group(panel: 'FeaturePanel', groups: 'Mapping[str, str]', minimum_group_size: 'int' = 2) -> 'NeutralizationReport'", + "neutralize_mean": "function alphalab.factor_library.neutralization.neutralize_mean(panel: 'FeaturePanel', minimum_assets: 'int' = 2) -> 'NeutralizationReport'", + "observation_frame": "function alphalab.factor_library.knowledge.observation_frame(view: 'ObservationView[ExternalObservation]', category: 'str', metric: 'str', clock: 'ResearchClock', subjects: 'Sequence[str]', *, max_age_seconds: 'float | None', policy: 'VintagePolicy') -> 'KnowledgeFrame'", + "observations_from_dataset": "function alphalab.factor_library.observations.observations_from_dataset(dataset: 'Dataset', source_field: 'FeatureField') -> 'ObservationFrame'", + "observations_from_price_series": "function alphalab.factor_library.observations.observations_from_price_series(prices: 'PriceSeries', source_field: 'FeatureField', timezone_name: 'str') -> 'ObservationFrame'", + "observations_from_records": "function alphalab.factor_library.observations.observations_from_records(records: 'Sequence[CanonicalRecord]', source_field: 'FeatureField', timezone_name: 'str', dataset_version: 'str | None' = None) -> 'ObservationFrame'", + "percentile_rank_panel": "function alphalab.factor_library.ranking.percentile_rank_panel(panel: 'FeaturePanel', method: 'RankMethod' = , minimum_assets: 'int' = 2) -> 'FactorRanking'", + "rank_panel": "function alphalab.factor_library.ranking.rank_panel(panel: 'FeaturePanel', method: 'RankMethod' = , minimum_assets: 'int' = 2) -> 'FactorRanking'", + "require_applicable": "function alphalab.factor_library.applicability.require_applicable(definition: 'FeatureDefinition', asset_class: 'DataAssetClass') -> 'None'", + "sample_knowledge": "function alphalab.factor_library.knowledge.sample_knowledge(timelines: 'Mapping[str, Sequence[KnowledgeStep]]', instants: 'Sequence[float]', max_age_seconds: 'float | None') -> 'tuple[dict[str, ObservationSeries], dict[str, tuple[tuple[str, ...], ...]], int, int]'", + "share_count_changes": "function alphalab.factor_library.fundamentals.share_count_changes(splits: 'Sequence[Split]', *, source: 'str', subjects: 'Mapping[str, str] | None' = None) -> 'tuple[ShareCountChange, ...]'", + "to_factor_results": "function alphalab.factor_library.compute.to_factor_results(series: 'FeatureSeries', version: 'int') -> 'tuple[FactorResult, ...]'", + "weights_from_buckets": "function alphalab.factor_library.turnover.weights_from_buckets(bucketed: 'FeaturePanel', buckets: 'int') -> 'dict[float, dict[str, float]]'" + }, + "alphalab.feature_store": { + "CacheHit": "class alphalab.feature_store.events.CacheHit(event_id: str, timestamp: float, feature_id: str, version: int) -> None", + "CacheInvalidated": "class alphalab.feature_store.events.CacheInvalidated(event_id: str, timestamp: float, feature_id: str, version: int) -> None", + "CacheMiss": "class alphalab.feature_store.events.CacheMiss(event_id: str, timestamp: float, feature_id: str, version: int) -> None", + "FeatureCache": "class alphalab.feature_store.cache.FeatureCache(entries: alphalab.common.persistent_map.PersistentMap[str, alphalab.feature_store.value.FeatureValue] = , hits: int = 0, misses: int = 0) -> None", + "FeatureDeprecated": "class alphalab.feature_store.events.FeatureDeprecated(event_id: str, timestamp: float, feature_id: str, version: int) -> None", + "FeatureMetadata": "class alphalab.feature_store.metadata.FeatureMetadata(feature_id: str, name: str, version: int, feature_type: alphalab.feature_store.metadata.FeatureType, value_type: alphalab.feature_store.metadata.FeatureValueType, owner: str, description: str, asset_scoped: bool = True, depends_on: tuple[str, ...] = , tags: collections.abc.Mapping[str, str] = , created_at: float = 0.0) -> None", + "FeatureNotFoundError": "class alphalab.feature_store.exceptions.FeatureNotFoundError", + "FeatureRegistered": "class alphalab.feature_store.events.FeatureRegistered(event_id: str, timestamp: float, feature_id: str, version: int) -> None", + "FeatureRegistry": "class alphalab.feature_store.registry.FeatureRegistry()", + "FeatureStoreEngine": "class alphalab.feature_store.engine.FeatureStoreEngine()", + "FeatureStoreError": "class alphalab.feature_store.exceptions.FeatureStoreError", + "FeatureStoreEvent": "class alphalab.feature_store.events.FeatureStoreEvent(event_id: str, timestamp: float) -> None", + "FeatureStoreState": "class alphalab.feature_store.state.FeatureStoreState(engine_id: str, features: alphalab.common.persistent_map.PersistentMap[str, alphalab.feature_store.metadata.FeatureMetadata] = , registered_feature_ids: alphalab.common.persistent_map.PersistentSet[str] = , deprecated_keys: alphalab.common.persistent_map.PersistentSet[str] = , values: alphalab.common.persistent_map.PersistentMap[str, alphalab.feature_store.value.FeatureValue] = , cache: alphalab.feature_store.cache.FeatureCache = , history: alphalab.common.append_log.AppendOnlyLog[alphalab.feature_store.decision.FeatureWriteDecision] = , events: alphalab.common.append_log.AppendOnlyLog[alphalab.feature_store.events.FeatureStoreEvent] = , statistics: alphalab.feature_store.state.FeatureStoreStatistics = , metadata: collections.abc.Mapping[str, str] = ) -> None", + "FeatureStoreStatistics": "class alphalab.feature_store.state.FeatureStoreStatistics(total_registered: int = 0, total_deprecated: int = 0, total_values_written: int = 0, total_values_rejected: int = 0) -> None", + "FeatureType": "enum alphalab.feature_store.metadata.FeatureType[PRICE, VOLUME, FUNDAMENTAL, ALTERNATIVE, CROSS_SECTIONAL, DERIVED]", + "FeatureValidationError": "class alphalab.feature_store.exceptions.FeatureValidationError", + "FeatureValue": "class alphalab.feature_store.value.FeatureValue(feature_id: str, version: int, asset_id: str | None, value: float | decimal.Decimal | int | bool | str, timestamp: float) -> None", + "FeatureValueAdapter": "class alphalab.feature_store.adapter.FeatureValueAdapter()", + "FeatureValueProtocol": "class alphalab.feature_store.protocol.FeatureValueProtocol(*args, **kwargs)", + "FeatureValueRejected": "class alphalab.feature_store.events.FeatureValueRejected(event_id: str, timestamp: float, decision_id: str, feature_id: str, version: int, reason: str) -> None", + "FeatureValueStore": "class alphalab.feature_store.store.FeatureValueStore()", + "FeatureValueType": "enum alphalab.feature_store.metadata.FeatureValueType[FLOAT, DECIMAL, INTEGER, BOOLEAN, STRING]", + "FeatureValueWritten": "class alphalab.feature_store.events.FeatureValueWritten(event_id: str, timestamp: float, decision_id: str, feature_id: str, version: int, asset_id: str | None) -> None", + "FeatureViolation": "class alphalab.feature_store.violations.FeatureViolation(rule: str, description: str, severity: str, feature_id: str, current_value: str, allowed_value: str) -> None", + "FeatureWriteDecision": "class alphalab.feature_store.decision.FeatureWriteDecision(decision_id: str, timestamp: float, feature_id: str, version: int, asset_id: str | None, approved: bool, reason: str, violations: tuple[alphalab.feature_store.violations.FeatureViolation, ...] = ) -> None", + "InvalidFeatureStateError": "class alphalab.feature_store.exceptions.InvalidFeatureStateError", + "active_features": "function alphalab.feature_store.views.active_features(state: alphalab.feature_store.state.FeatureStoreState) -> collections.abc.Sequence[alphalab.feature_store.metadata.FeatureMetadata]", + "cache_key": "function alphalab.feature_store.cache.cache_key(feature_id: str, version: int, asset_id: str | None) -> str", + "check_asset_scope": "function alphalab.feature_store.checks.check_asset_scope(value: alphalab.feature_store.value.FeatureValue, metadata: alphalab.feature_store.metadata.FeatureMetadata) -> alphalab.feature_store.violations.FeatureViolation | None", + "check_dependencies_registered": "function alphalab.feature_store.checks.check_dependencies_registered(metadata: alphalab.feature_store.metadata.FeatureMetadata, state: alphalab.feature_store.state.FeatureStoreState) -> alphalab.feature_store.violations.FeatureViolation | None", + "check_feature_registered": "function alphalab.feature_store.checks.check_feature_registered(value: alphalab.feature_store.value.FeatureValue, state: alphalab.feature_store.state.FeatureStoreState) -> alphalab.feature_store.violations.FeatureViolation | None", + "check_value_type": "function alphalab.feature_store.checks.check_value_type(value: alphalab.feature_store.value.FeatureValue, metadata: alphalab.feature_store.metadata.FeatureMetadata) -> alphalab.feature_store.violations.FeatureViolation | None", + "deprecated_features": "function alphalab.feature_store.views.deprecated_features(state: alphalab.feature_store.state.FeatureStoreState) -> collections.abc.Sequence[alphalab.feature_store.metadata.FeatureMetadata]", + "feature_statistics": "function alphalab.feature_store.views.feature_statistics(state: alphalab.feature_store.state.FeatureStoreState) -> alphalab.feature_store.state.FeatureStoreStatistics", + "get_metadata": "function alphalab.feature_store.views.get_metadata(state: alphalab.feature_store.state.FeatureStoreState, feature_id: str, version: int) -> alphalab.feature_store.metadata.FeatureMetadata | None", + "latest_value": "function alphalab.feature_store.views.latest_value(state: alphalab.feature_store.state.FeatureStoreState, feature_id: str, version: int, asset_id: str | None) -> alphalab.feature_store.value.FeatureValue | None", + "list_features": "function alphalab.feature_store.views.list_features(state: alphalab.feature_store.state.FeatureStoreState) -> collections.abc.Sequence[alphalab.feature_store.metadata.FeatureMetadata]", + "list_versions": "function alphalab.feature_store.views.list_versions(state: alphalab.feature_store.state.FeatureStoreState, feature_id: str) -> collections.abc.Sequence[int]", + "validate_feature_metadata": "function alphalab.feature_store.validation.validate_feature_metadata(metadata: alphalab.feature_store.metadata.FeatureMetadata) -> None", + "validate_lookup": "function alphalab.feature_store.validation.validate_lookup(state: alphalab.feature_store.state.FeatureStoreState, feature_id: str, version: int) -> None", + "validate_registration": "function alphalab.feature_store.validation.validate_registration(state: alphalab.feature_store.state.FeatureStoreState, metadata: alphalab.feature_store.metadata.FeatureMetadata) -> None", + "write_history": "function alphalab.feature_store.views.write_history(state: alphalab.feature_store.state.FeatureStoreState) -> collections.abc.Sequence[alphalab.feature_store.decision.FeatureWriteDecision]" + }, + "alphalab.futures": { + "AdjustmentMethod": "enum alphalab.futures.roll.AdjustmentMethod[UNADJUSTED, BACK_ADJUSTED, RATIO_ADJUSTED]", + "CalendarSpread": "class alphalab.futures.spread.CalendarSpread(legs: tuple[alphalab.futures.spread.CalendarSpreadLeg, ...]) -> None", + "CalendarSpreadLeg": "class alphalab.futures.spread.CalendarSpreadLeg(contract: alphalab.futures.contract.FutureContract, side: alphalab.core.enums.Side, quantity: int) -> None", + "ContractChain": "class alphalab.futures.chain.ContractChain(root: 'str', contracts: 'tuple[FutureContract, ...]') -> None", + "ContractMarginSpec": "class alphalab.futures.margin.ContractMarginSpec(contract_symbol: 'str', initial: 'Decimal', maintenance: 'Decimal', currency: 'str', as_of: 'float') -> None", + "CurveShape": "enum alphalab.futures.curve.CurveShape[CONTANGO, BACKWARDATION, FLAT, MIXED]", + "FutureContract": "class alphalab.futures.contract.FutureContract(underlying_asset_id: str, contract_month: float, expiry: float, multiplier: int, tick_size: decimal.Decimal, currency: str) -> None", + "FuturesComputationError": "class alphalab.futures.exceptions.FuturesComputationError", + "FuturesCurve": "class alphalab.futures.curve.FuturesCurve(underlying_asset_id: str, timestamp: float, points: tuple[alphalab.futures.curve.FuturesCurvePoint, ...]) -> None", + "FuturesCurvePoint": "class alphalab.futures.curve.FuturesCurvePoint(contract_month: float, price: decimal.Decimal) -> None", + "FuturesError": "class alphalab.futures.exceptions.FuturesError", + "FuturesInputError": "class alphalab.futures.exceptions.FuturesInputError", + "MarginPosture": "class alphalab.futures.margin.MarginPosture(contract_symbol: 'str', contracts: 'Decimal', initial: 'Decimal', maintenance: 'Decimal', currency: 'str', as_of: 'float') -> None", + "RollEvent": "class alphalab.futures.chain.RollEvent(timestamp: 'float', outgoing: 'FutureContract', incoming: 'FutureContract', trigger: 'RollTrigger', reason: 'str') -> None", + "RollPolicy": "class alphalab.futures.chain.RollPolicy(trigger: 'RollTrigger', offset_days: 'int | None' = None) -> None", + "RollSegment": "class alphalab.futures.roll.RollSegment(contract: alphalab.futures.contract.FutureContract, bars: tuple[alphalab.market.bar.Bar, ...], outgoing_roll_price: decimal.Decimal | None = None, incoming_roll_price: decimal.Decimal | None = None) -> None", + "RollTrigger": "enum alphalab.futures.chain.RollTrigger[CALENDAR_DAYS_BEFORE_EXPIRY, TRADING_DAYS_BEFORE_EXPIRY, VOLUME_CROSSOVER]", + "SECONDS_PER_YEAR": "value builtins.float", + "SessionCalendar": "class alphalab.futures.chain.SessionCalendar(*args, **kwargs)", + "active_contract_at": "function alphalab.futures.chain.active_contract_at(chain: 'ContractChain', rolls: 'Sequence[RollEvent]', timestamp: 'float') -> 'FutureContract'", + "build_continuous_series": "function alphalab.futures.roll.build_continuous_series(segments: tuple[alphalab.futures.roll.RollSegment, ...], method: alphalab.futures.roll.AdjustmentMethod) -> tuple[alphalab.market.bar.Bar, ...]", + "compute_pnl": "function alphalab.futures.spread.compute_pnl(spread: alphalab.futures.spread.CalendarSpread, entry_prices: collections.abc.Mapping[str, decimal.Decimal], current_prices: collections.abc.Mapping[str, decimal.Decimal]) -> decimal.Decimal", + "compute_spread_value": "function alphalab.futures.spread.compute_spread_value(spread: alphalab.futures.spread.CalendarSpread, prices: collections.abc.Mapping[str, decimal.Decimal]) -> decimal.Decimal", + "continuous_segments": "function alphalab.futures.chain.continuous_segments(chain: 'ContractChain', rolls: 'Sequence[RollEvent]', observations: 'Mapping[str, Sequence[Bar]]') -> 'tuple[RollSegment, ...]'", + "contract_tick_value": "function alphalab.futures.contract.contract_tick_value(contract: alphalab.futures.contract.FutureContract) -> alphalab.conventions.tick.TickValue", + "curve_shape": "function alphalab.futures.curve.curve_shape(curve: alphalab.futures.curve.FuturesCurve) -> alphalab.futures.curve.CurveShape", + "curve_slope": "function alphalab.futures.curve.curve_slope(curve: alphalab.futures.curve.FuturesCurve) -> decimal.Decimal", + "futures_symbol": "function alphalab.futures.contract.futures_symbol(contract: alphalab.futures.contract.FutureContract) -> str", + "is_backwardation": "function alphalab.futures.curve.is_backwardation(curve: alphalab.futures.curve.FuturesCurve) -> bool", + "is_contango": "function alphalab.futures.curve.is_contango(curve: alphalab.futures.curve.FuturesCurve) -> bool", + "open_future_position": "function alphalab.futures.contract.open_future_position(contract: alphalab.futures.contract.FutureContract, quantity: decimal.Decimal, price: decimal.Decimal, timestamp: float, *, minor_units: int | None = None) -> alphalab.portfolio.position.Position", + "position_margin": "function alphalab.futures.margin.position_margin(contract: 'FutureContract', quantity: 'Decimal', specifications: 'Mapping[str, ContractMarginSpec]', as_of: 'float') -> 'MarginPosture'", + "roll_schedule": "function alphalab.futures.chain.roll_schedule(chain: 'ContractChain', policy: 'RollPolicy', calendar: 'SessionCalendar | None' = None, observations: 'Mapping[str, Sequence[Bar]] | None' = None) -> 'tuple[RollEvent, ...]'", + "roll_yield": "function alphalab.futures.curve.roll_yield(curve: alphalab.futures.curve.FuturesCurve, near_month: float, far_month: float) -> decimal.Decimal", + "sorted_by_month": "function alphalab.futures.curve.sorted_by_month(curve: alphalab.futures.curve.FuturesCurve) -> tuple[alphalab.futures.curve.FuturesCurvePoint, ...]" + }, + "alphalab.instrument": { + "ALPHALAB_INSTRUMENT_NAMESPACE": "value uuid.UUID", + "AutomaticExercise": "class alphalab.instrument.terms.AutomaticExercise(threshold: 'Decimal') -> None", + "CASH_EQUITY": "value alphalab.conventions.economics.InstrumentEconomics", + "ClassificationHistory": "class alphalab.instrument.classification.ClassificationHistory(entries: 'tuple[SectorClassification, ...]' = ) -> None", + "ContractTerms": "alias ContractTerms", + "DatedAlias": "class alphalab.instrument.record.DatedAlias(provider: 'str', symbol: 'str', valid_from: 'float | None', valid_to: 'float | None') -> None", + "Deliverable": "class alphalab.instrument.terms.Deliverable(asset_id: 'str', units: 'Decimal') -> None", + "DimensionClassification": "class alphalab.instrument.classification.DimensionClassification(label: 'str | None', source: 'str' = 'operator', as_of: 'float | None' = None) -> None", + "DimensionHistory": "class alphalab.instrument.classification.DimensionHistory(entries: 'tuple[DimensionClassification, ...]' = ) -> None", + "FutureTerms": "class alphalab.instrument.terms.FutureTerms(settlement: 'SettlementStyle', deliverable: 'Deliverable | None') -> None", + "INSTRUMENT_KEY_SCHEME": "value builtins.str", + "InstrumentEconomics": "class alphalab.conventions.economics.InstrumentEconomics(multiplier: 'Decimal', settlement: 'SettlementModel', lot: 'LotSpecification | None' = None, minimum_notional: 'Decimal | None' = None, allows_negative_prices: 'bool' = False) -> None", + "InstrumentError": "class alphalab.instrument.exceptions.InstrumentError", + "InstrumentInputError": "class alphalab.instrument.exceptions.InstrumentInputError", + "InstrumentRecord": "class alphalab.instrument.record.InstrumentRecord(symbol: 'str', asset_type: 'AssetType', exchange: 'str', currency: 'str', sector: 'str | None' = None, aliases: 'Mapping[str, str]' = , dated_aliases: 'tuple[DatedAlias, ...]' = (), economics: 'InstrumentEconomics | None' = None, last_trade_at: 'float | None' = None, terms: 'ContractTerms | None' = None) -> None", + "InstrumentRegistrationError": "class alphalab.instrument.exceptions.InstrumentRegistrationError", + "InstrumentRegistry": "class alphalab.instrument.registry.InstrumentRegistry(instruments: 'PersistentMap[str, InstrumentRecord]' = , by_provider: 'PersistentMap[str, PersistentMap[str, str]]' = , classifications: 'PersistentMap[str, ClassificationHistory]' = , dated: 'PersistentMap[str, PersistentMap[str, tuple[tuple[DatedAlias, str], ...]]]' = , dimensions: 'PersistentMap[str, PersistentMap[str, DimensionHistory]]' = , members: 'PersistentMap[str, PersistentMap[str, PersistentMap[str, None]]] | None' = None, expiring: 'PersistentMap[str, float] | None' = None) -> None", + "OPERATOR": "value builtins.str", + "OptionTerms": "class alphalab.instrument.terms.OptionTerms(option_type: 'OptionType', strike: 'Decimal', exercise_style: 'ExerciseStyle', settlement: 'SettlementStyle', deliverable: 'Deliverable', expires_at: 'float', automatic_exercise: 'AutomaticExercise | None', exercisable_from: 'float | None' = None) -> None", + "SECTOR": "value builtins.str", + "SectorClassification": "class alphalab.instrument.classification.SectorClassification(sector: 'str | None', source: 'str' = 'operator', as_of: 'float | None' = None) -> None", + "SettlementModel": "enum alphalab.conventions.economics.SettlementModel[CASH_EQUITY, FUTURES_VARIATION, OPTION_PREMIUM, PERPETUAL]", + "canonical_instrument_key": "function alphalab.instrument.identity.canonical_instrument_key(asset_type: 'AssetType', exchange: 'str', symbol: 'str', currency: 'str') -> 'str'", + "classification_history": "function alphalab.instrument.registry.classification_history(registry: 'InstrumentRegistry', asset_id: 'str') -> 'ClassificationHistory'", + "classification_of": "function alphalab.instrument.registry.classification_of(registry: 'InstrumentRegistry', asset_id: 'str') -> 'SectorClassification | None'", + "classify_dimension": "function alphalab.instrument.registry.classify_dimension(registry: 'InstrumentRegistry', asset_id: 'str', dimension: 'str', label: 'str | None', source: 'str' = 'operator', as_of: 'float | None' = None) -> 'InstrumentRegistry'", + "classify_dimensions": "function alphalab.instrument.registry.classify_dimensions(registry: 'InstrumentRegistry', dimension: 'str', labels: 'Mapping[str, str | None]', source: 'str' = 'operator', as_of: 'float | None' = None) -> 'InstrumentRegistry'", + "classify_instrument": "function alphalab.instrument.registry.classify_instrument(registry: 'InstrumentRegistry', asset_id: 'str', sector: 'str | None', source: 'str' = 'operator', as_of: 'float | None' = None) -> 'InstrumentRegistry'", + "classify_instruments": "function alphalab.instrument.registry.classify_instruments(registry: 'InstrumentRegistry', classifications: 'Mapping[str, str | None]', source: 'str' = 'operator', as_of: 'float | None' = None) -> 'InstrumentRegistry'", + "derive_asset_id": "function alphalab.instrument.identity.derive_asset_id(asset_type: 'AssetType', exchange: 'str', symbol: 'str', currency: 'str') -> 'str'", + "dimension_history": "function alphalab.instrument.registry.dimension_history(registry: 'InstrumentRegistry', asset_id: 'str', dimension: 'str') -> 'DimensionHistory'", + "economics_for": "function alphalab.instrument.economics.economics_for(asset_type: 'AssetType', declared: 'InstrumentEconomics | None', name: 'str' = 'the instrument') -> 'InstrumentEconomics'", + "get_instrument": "function alphalab.instrument.registry.get_instrument(registry: 'InstrumentRegistry', asset_id: 'str') -> 'InstrumentRecord'", + "label_as_of": "function alphalab.instrument.registry.label_as_of(registry: 'InstrumentRegistry', asset_id: 'str', dimension: 'str', timestamp: 'float') -> 'str | None'", + "normalize_dimension": "function alphalab.instrument.classification.normalize_dimension(value: 'str') -> 'str'", + "normalize_key_field": "function alphalab.instrument.record.normalize_key_field(value: 'str', field_name: 'str') -> 'str'", + "normalize_sector_label": "function alphalab.instrument.record.normalize_sector_label(value: 'str', field_name: 'str' = 'sector') -> 'str'", + "register_alias": "function alphalab.instrument.registry.register_alias(registry: 'InstrumentRegistry', asset_id: 'str', provider: 'str', symbol: 'str') -> 'InstrumentRegistry'", + "register_dated_alias": "function alphalab.instrument.registry.register_dated_alias(registry: 'InstrumentRegistry', asset_id: 'str', alias: 'DatedAlias') -> 'InstrumentRegistry'", + "register_instrument": "function alphalab.instrument.registry.register_instrument(registry: 'InstrumentRegistry', record: 'InstrumentRecord') -> 'InstrumentRegistry'", + "register_instruments": "function alphalab.instrument.registry.register_instruments(registry: 'InstrumentRegistry', records: 'Iterable[InstrumentRecord]') -> 'InstrumentRegistry'", + "sector_as_of": "function alphalab.instrument.registry.sector_as_of(registry: 'InstrumentRegistry', asset_id: 'str', timestamp: 'float') -> 'str | None'" + }, + "alphalab.instrument.snapshot": { + "ClassificationRecord": "class alphalab.instrument.snapshot.ClassificationRecord(sector: 'str | None', source: 'str', as_of: 'float | None') -> None", + "DatedAliasRecord": "class alphalab.instrument.snapshot.DatedAliasRecord(provider: 'str', symbol: 'str', valid_from: 'float | None', valid_to: 'float | None') -> None", + "INSTRUMENT_SCHEMA_HISTORY": "value alphalab.persistence.upgrade.SchemaHistory", + "INSTRUMENT_SNAPSHOT_SCHEMA": "value builtins.int", + "InstrumentRegistryRecord": "class alphalab.instrument.snapshot.InstrumentRegistryRecord(symbol: 'str', asset_type: 'AssetType', exchange: 'str', currency: 'str', sector: 'str | None', aliases: 'Mapping[str, str]', classifications: 'tuple[ClassificationRecord, ...]', dated_aliases: 'tuple[DatedAliasRecord, ...]' = (), later_aliases: 'tuple[tuple[str, str], ...]' = (), later_dated_aliases: 'tuple[DatedAliasRecord, ...]' = (), economics: 'InstrumentEconomics | None' = None, dimensions: 'Mapping[str, tuple[DimensionRecord, ...]]' = , last_trade_at: 'float | None' = None, terms: 'Mapping[str, Any] | None' = None) -> None", + "InstrumentRegistrySnapshot": "class alphalab.instrument.snapshot.InstrumentRegistrySnapshot(instruments: 'tuple[InstrumentRegistryRecord, ...]', schema_version: 'int' = 4) -> None", + "capture": "function alphalab.instrument.snapshot.capture(registry: 'InstrumentRegistry') -> 'InstrumentRegistrySnapshot'", + "from_primitives": "function alphalab.instrument.snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'InstrumentRegistrySnapshot'", + "restore": "function alphalab.instrument.snapshot.restore(snapshot: 'InstrumentRegistrySnapshot') -> 'InstrumentRegistry'" + }, + "alphalab.lifecycle": { + "ADAPTIVE_SETTING_PREFIX": "value builtins.str", + "AccountMirror": "class alphalab.lifecycle.reconciliation.AccountMirror(broker: 'BrokerState', mapping: 'ExternalOrderMap', symbols: 'SymbolMapping', children: 'ChildOrderBindings | None' = None) -> None", + "AdaptiveReplayAssessment": "class alphalab.lifecycle.reproducibility.AdaptiveReplayAssessment(replay_id: 'str', record_verified: 'bool', rerun: 'RerunOutcome', detail: 'tuple[str, ...]', first_divergence: 'int | None') -> None", + "AlignmentKey": "enum alphalab.lifecycle.comparison.AlignmentKey[ORDER_ID, ASSET_AND_TIME]", + "ApprovalRecord": "class alphalab.lifecycle.governance.ApprovalRecord(name: 'str', version: 'int', environment: 'str', approver_id: 'str', timestamp: 'float', note: 'str' = '') -> None", + "BROKER_STATUS_EQUIVALENTS": "value builtins.mappingproxy", + "BrokerCapabilities": "class alphalab.lifecycle.specification.BrokerCapabilities(order_types: 'frozenset[OrderType]', time_in_force: 'frozenset[TimeInForce]', asset_classes: 'frozenset[AssetType]', short_selling: 'bool', fractional_quantities: 'bool') -> None", + "BrokerRequirements": "class alphalab.lifecycle.specification.BrokerRequirements(order_types: 'frozenset[OrderType]', time_in_force: 'frozenset[TimeInForce]', asset_classes: 'frozenset[AssetType]', short_selling: 'bool', fractional_quantities: 'bool') -> None", + "CAPITAL_SETTING_PREFIX": "value builtins.str", + "CERTIFICATION_REPORT_SCHEME": "value builtins.str", + "COMPONENT_EVIDENCE": "value builtins.str", + "COMPONENT_MODEL": "value builtins.str", + "COMPONENT_RUN": "value builtins.str", + "COMPONENT_STRATEGY": "value builtins.str", + "CapitalPlanIdentity": "class alphalab.lifecycle.fingerprint.CapitalPlanIdentity(*args, **kwargs)", + "CapitalPolicy": "class alphalab.lifecycle.specification.CapitalPolicy(account_id: 'str', base_currency: 'str', allocated_capital: 'Decimal', settlement_currencies: 'tuple[str, ...]') -> None", + "CertificationEvidence": "class alphalab.lifecycle.certification.CertificationEvidence(runs: 'tuple[BacktestResult, ...]' = (), repeated_runs: 'tuple[BacktestResult, ...]' = (), datasets: 'tuple[Dataset, ...]' = (), manifest: 'ReproducibilityManifest | None' = None, rerun: 'ReproducibilityManifest | None' = None, observations: 'tuple[RuntimeObservation, ...]' = (), measurements: 'tuple[ResourceMeasurement, ...]' = ()) -> None", + "CertificationProperty": "enum alphalab.lifecycle.certification.CertificationProperty[DETERMINISTIC, REPRODUCIBLE, RISK_LIMITS, MAX_LEVERAGE, SUPPORTED_MARKETS, REQUIRED_DATA, RESOURCE_USAGE, RUNTIME_BEHAVIOR]", + "CertificationReport": "class alphalab.lifecycle.certification.CertificationReport(report_id: 'str', fingerprint: 'str', specification_id: 'str', stage: 'StrategyLifecycleStage | None', specification_findings: 'tuple[str, ...]', assessments: 'tuple[PropertyAssessment, ...]') -> None", + "CertificationStatus": "enum alphalab.lifecycle.certification.CertificationStatus[PASS, FAIL, NOT_ASSESSED, INSUFFICIENT_EVIDENCE]", + "CodeIdentity": "class alphalab.lifecycle.fingerprint.CodeIdentity(package: 'str', version: 'str | None', entry_point: 'str', source_digest: 'str | None') -> None", + "ComparisonEntry": "class alphalab.lifecycle.comparison.ComparisonEntry(metric: 'ComparisonMetric', key: 'str', outcome: 'ComparisonOutcome', expected: 'Decimal | None', observed: 'Decimal | None', detail: 'str' = '') -> None", + "ComparisonMetric": "enum alphalab.lifecycle.comparison.ComparisonMetric[TRADE_QUANTITY, TRADE_PRICE, FILL_QUANTITY, FILL_PRICE, SLIPPAGE, EXECUTION_LATENCY, REALIZED_PNL, EXPOSURE]", + "ComparisonOutcome": "enum alphalab.lifecycle.comparison.ComparisonOutcome[EXACT_MATCH, WITHIN_TOLERANCE, MATERIAL_DIFFERENCE, MISSING_EXPECTED, MISSING_OBSERVED, NOT_COMPARABLE]", + "ComparisonSource": "enum alphalab.lifecycle.comparison.ComparisonSource[EXPECTED, PAPER, LIVE]", + "ConstructionIdentity": "class alphalab.lifecycle.fingerprint.ConstructionIdentity(*args, **kwargs)", + "DEPLOYABLE_STAGES": "value builtins.tuple", + "DEPLOYMENT_SPECIFICATION_SCHEME": "value builtins.str", + "DatasetAssumption": "class alphalab.lifecycle.specification.DatasetAssumption(role: 'str', dataset_version: 'str') -> None", + "DatasetProvenanceView": "class alphalab.lifecycle.reproducibility.DatasetProvenanceView(*args, **kwargs)", + "DependencyCompleteness": "enum alphalab.lifecycle.fingerprint.DependencyCompleteness[EXACT_CLOSURE, DIRECT_ONLY, UNDECLARED]", + "DependencyManifest": "class alphalab.lifecycle.fingerprint.DependencyManifest(completeness: 'DependencyCompleteness', pins: 'tuple[DependencyPin, ...]') -> None", + "DependencyPin": "class alphalab.lifecycle.fingerprint.DependencyPin(name: 'str', version: 'str', artifact_sha256: 'str | None' = None) -> None", + "DeploymentRef": "class alphalab.lifecycle.identity.DeploymentRef(environment: 'str', release_name: 'str', release_version: 'int') -> None", + "DeploymentSpecification": "class alphalab.lifecycle.specification.DeploymentSpecification(specification_id: 'str', strategy: 'StrategyVersionRef', parameters: 'Mapping[str, ParamValue]', datasets: 'tuple[DatasetAssumption, ...]', risk: 'RiskLimits', capital: 'CapitalPolicy', broker: 'BrokerRequirements', market: 'MarketRequirements', runtime: 'RuntimeRequirements') -> None", + "EXECUTION_SETTING_PREFIX": "value builtins.str", + "EngineBuild": "class alphalab.lifecycle.fingerprint.EngineBuild(source_digest: 'str', tz_database: 'str | None') -> None", + "EngineIdentity": "class alphalab.lifecycle.fingerprint.EngineIdentity(name: 'str', version: 'str') -> None", + "EnvironmentPortability": "class alphalab.lifecycle.portability.EnvironmentPortability(environment: 'str', mode: 'ExecutionMode', status: 'PortabilityStatus', checks: 'tuple[RequirementCheck, ...]') -> None", + "ExecutionAlgorithmIdentity": "class alphalab.lifecycle.fingerprint.ExecutionAlgorithmIdentity(*args, **kwargs)", + "ExecutionObservation": "class alphalab.lifecycle.health.ExecutionObservation(order_id: 'str', submitted_at: 'float | None', executed_at: 'float | None', rejected: 'bool' = False, detail: 'str' = '') -> None", + "ExternalInput": "enum alphalab.lifecycle.reproducibility.ExternalInput[DATASET_BYTES, STRATEGY_CODE, DEPENDENCIES, ENGINE, LIVE_OBJECTS, NORMALIZATION_POLICY, CONTEXT_FACTORY, VENUE_EXECUTIONS, AUXILIARY_DATA]", + "ExternalRequirement": "class alphalab.lifecycle.reproducibility.ExternalRequirement(input: 'ExternalInput', detail: 'str', recreatable: 'bool') -> None", + "FillObservation": "class alphalab.lifecycle.comparison.FillObservation(key: 'str', asset_id: 'str', side: 'Side', quantity: 'Decimal', price: 'Decimal', commission: 'Decimal', filled_at: 'float', slippage: 'Decimal | None' = None, latency_seconds: 'Decimal | None' = None) -> None", + "Governance": "class alphalab.lifecycle.governance.Governance(authority: 'PermissionAuthority', actor_id: 'str', approval_required_in: 'frozenset[str]' = ) -> None", + "GovernedAct": "class alphalab.lifecycle.governance.GovernedAct(action: 'str', subject: 'str', actor_id: 'str', timestamp: 'float', detail: 'str' = '') -> None", + "HealthCategory": "enum alphalab.lifecycle.health.HealthCategory[STALE_DATA, ABNORMAL_EXECUTION, UNEXPECTED_POSITION, RISK_BREACH, HEARTBEAT_LOSS, BROKER_DISCONNECT, EXPECTED_STATE_DIVERGENCE]", + "HealthFinding": "class alphalab.lifecycle.health.HealthFinding(category: 'HealthCategory', severity: 'HealthSeverity', subject: 'str', summary: 'str', observed_at: 'float', detail: 'Mapping[str, str]' = ) -> None", + "HealthReport": "class alphalab.lifecycle.health.HealthReport(specification_id: 'str', evaluated_at: 'float', findings: 'tuple[HealthFinding, ...]' = (), unevaluated: 'tuple[UnevaluatedCategory, ...]' = ()) -> None", + "HealthSeverity": "enum alphalab.lifecycle.health.HealthSeverity[WARNING, BREACH]", + "HealthStatus": "enum alphalab.lifecycle.health.HealthStatus[HEALTHY, UNKNOWN, DEGRADED, BREACHED]", + "HealthWindowReport": "class alphalab.lifecycle.health.HealthWindowReport(specification_id: 'str', start: 'float', end: 'float', reports: 'tuple[HealthReport, ...]', occurrences: 'Mapping[HealthCategory, int]', persistent: 'tuple[HealthCategory, ...]', never_evaluated: 'tuple[HealthCategory, ...]') -> None", + "INITIAL_STAGE": "value alphalab.lifecycle.progression.StrategyLifecycleStage", + "LEGAL_PROGRESSION_TRANSITIONS": "value builtins.dict", + "LIFECYCLE_PERMISSIONS": "value builtins.frozenset", + "LifecycleError": "class alphalab.lifecycle.exceptions.LifecycleError", + "LifecycleInputError": "class alphalab.lifecycle.exceptions.LifecycleInputError", + "LifecyclePermissionError": "class alphalab.lifecycle.exceptions.LifecyclePermissionError", + "LifecycleState": "class alphalab.lifecycle.state.LifecycleState(experiments: 'ExperimentTracker' = , models: 'ModelRegistry' = , strategies: 'StrategyVersionRegistry' = , deployments: 'DeploymentManager' = , evidence: 'PersistentMap[str, ValidationEvidence]' = , releases: 'PersistentMap[str, int]' = , approvals: 'AppendOnlyLog[ApprovalRecord]' = ) -> None", + "LifecycleTransitionError": "class alphalab.lifecycle.exceptions.LifecycleTransitionError", + "LockFormat": "enum alphalab.lifecycle.lockfile.LockFormat[PIP_COMPILE, REQUIREMENTS, UV_LOCK, POETRY_LOCK]", + "MONETARY_METRICS": "value builtins.tuple", + "MarketAvailability": "class alphalab.lifecycle.specification.MarketAvailability(instruments: 'frozenset[str]', venues: 'frozenset[str]', calendar_ids: 'frozenset[str]', quote_currencies: 'frozenset[str]') -> None", + "MarketRequirements": "class alphalab.lifecycle.specification.MarketRequirements(instruments: 'tuple[str, ...]', venues: 'tuple[str, ...]', calendar_ids: 'tuple[str, ...]', quote_currencies: 'tuple[str, ...]') -> None", + "MeasurementBasis": "enum alphalab.lifecycle.certification.MeasurementBasis[COUNTED, MEASURED, ESTIMATED]", + "MetricThreshold": "class alphalab.lifecycle.evidence.MetricThreshold(metric: 'str', minimum: 'float | None' = None, maximum: 'float | None' = None) -> None", + "Mismatch": "class alphalab.lifecycle.reconciliation.Mismatch(category: 'MismatchCategory', key: 'str', expected: 'str | None', observed: 'str | None', reason: 'str', account: 'str | None' = None) -> None", + "MismatchCategory": "enum alphalab.lifecycle.reconciliation.MismatchCategory[MISSING_EXPECTED_ORDER, UNEXPECTED_OBSERVED_ORDER, ORDER_QUANTITY_MISMATCH, ORDER_PRICE_MISMATCH, ORDER_STATUS_MISMATCH, MISSING_EXPECTED_FILL, UNEXPECTED_OBSERVED_FILL, FILL_QUANTITY_MISMATCH, EXECUTION_MISMATCH, POSITION_QUANTITY_MISMATCH, UNEXPECTED_POSITION, INSTRUMENT_MISMATCH, ACCOUNT_CASH_MISMATCH, LIFECYCLE_STATE_MISMATCH, ACCOUNT_ASSIGNMENT_MISMATCH]", + "ModelRef": "class alphalab.lifecycle.identity.ModelRef(name: 'str', version: 'int') -> None", + "NO_DEPENDENCIES": "value alphalab.lifecycle.fingerprint.DependencyManifest", + "PAUSABLE_STAGES": "value builtins.tuple", + "PERMISSION_APPROVE": "value builtins.str", + "PERMISSION_DEPLOY": "value builtins.str", + "PERMISSION_PROMOTE": "value builtins.str", + "PERMISSION_RETIRE": "value builtins.str", + "PERMISSION_ROLLBACK": "value builtins.str", + "PORTABILITY_REPORT_SCHEME": "value builtins.str", + "PORTFOLIO_SETTING_PREFIX": "value builtins.str", + "PROGRESSION_MODEL_STAGES": "value builtins.dict", + "PairComparison": "class alphalab.lifecycle.comparison.PairComparison(left: 'ComparisonSource', right: 'ComparisonSource', entries: 'tuple[ComparisonEntry, ...]' = ()) -> None", + "PermissionAuthority": "class alphalab.lifecycle.governance.PermissionAuthority(*args, **kwargs)", + "PortabilityReport": "class alphalab.lifecycle.portability.PortabilityReport(report_id: 'str', fingerprint: 'str', specification_id: 'str', environments: 'tuple[EnvironmentPortability, ...]') -> None", + "PortabilityRequirement": "enum alphalab.lifecycle.portability.PortabilityRequirement[STRATEGY_LOGIC, EXECUTION, MARKET, DATA, CONTRACTS, RUNTIME, RISK, CAPITAL]", + "PortabilityStatus": "enum alphalab.lifecycle.portability.PortabilityStatus[PORTABLE, NOT_PORTABLE, INSUFFICIENT_EVIDENCE]", + "PropertyAssessment": "class alphalab.lifecycle.certification.PropertyAssessment(claim: 'CertificationProperty', status: 'CertificationStatus', methodology: 'str', evidence: 'Mapping[str, str]', findings: 'tuple[str, ...]', limitations: 'tuple[str, ...]') -> None", + "REPRODUCIBILITY_MANIFEST_SCHEME": "value builtins.str", + "RERUN_DIFFERENCE_LIMIT": "value builtins.int", + "ROUTING_SETTING_PREFIX": "value builtins.str", + "ReconciliationTolerances": "class alphalab.lifecycle.reconciliation.ReconciliationTolerances(order_quantity: 'Tolerance', order_price: 'Tolerance', fill_quantity: 'Tolerance', fill_price: 'Tolerance', commission: 'Tolerance', position_quantity: 'Tolerance', cash: 'Tolerance') -> None", + "ReproducibilityAssessment": "class alphalab.lifecycle.reproducibility.ReproducibilityAssessment(manifest_id: 'str', identity_verified: 'bool', gaps: 'tuple[str, ...]', rerun: 'RerunOutcome', rerun_detail: 'tuple[str, ...]', external_requirements: 'tuple[ExternalRequirement, ...]', findings: 'tuple[str, ...]') -> None", + "ReproducibilityManifest": "class alphalab.lifecycle.reproducibility.ReproducibilityManifest(manifest_id: 'str', kind: 'ResultKind', result_id: 'str', dataset_version: 'str', dataset_content_hash: 'str', configuration: 'str', configuration_id: 'str', seed: 'int | None', seed_role: 'SeedRole', engine: 'EngineIdentity', fingerprint: 'StrategyFingerprint | None', build: 'EngineBuild | None' = None, schedule_digest: 'str | None' = None, dataset_parts: 'tuple[tuple[str, str], ...]' = (), execution_plan_id: 'str | None' = None, queue_model: 'str | None' = None) -> None", + "RequirementCheck": "class alphalab.lifecycle.portability.RequirementCheck(requirement: 'PortabilityRequirement', outcome: 'RequirementOutcome', detail: 'str', blockers: 'tuple[str, ...]' = ()) -> None", + "RequirementOutcome": "enum alphalab.lifecycle.portability.RequirementOutcome[SATISFIED, BLOCKED, NOT_VERIFIED, NOT_APPLICABLE]", + "RerunOutcome": "enum alphalab.lifecycle.reproducibility.RerunOutcome[NOT_ATTEMPTED, REPRODUCED, DIVERGED, INPUTS_DIFFER]", + "RerunReport": "class alphalab.lifecycle.rerun.RerunReport(manifest_id: 'str', outcome: 'RerunOutcome', detail: 'tuple[str, ...]', assessment: 'ReproducibilityAssessment | None', rerun_manifest: 'ReproducibilityManifest | None', differences: 'tuple[str, ...]') -> None", + "ResearchConfiguration": "class alphalab.lifecycle.fingerprint.ResearchConfiguration(settings: 'Mapping[str, str]', study_id: 'str | None' = None) -> None", + "ResourceBudget": "class alphalab.lifecycle.certification.ResourceBudget(metric: 'ResourceMetric', maximum: 'Decimal') -> None", + "ResourceMeasurement": "class alphalab.lifecycle.certification.ResourceMeasurement(metric: 'ResourceMetric', value: 'Decimal', basis: 'MeasurementBasis', method: 'str', environment: 'str') -> None", + "ResourceMetric": "enum alphalab.lifecycle.certification.ResourceMetric[RECORDS_PROCESSED, ORDERS_SUBMITTED, FILLS, CPU_SECONDS, WALL_CLOCK_SECONDS, PEAK_MEMORY_BYTES]", + "ResultKind": "enum alphalab.lifecycle.reproducibility.ResultKind[RUN, STUDY]", + "RoutingPolicyIdentity": "class alphalab.lifecycle.fingerprint.RoutingPolicyIdentity(*args, **kwargs)", + "RunAuthorization": "class alphalab.lifecycle.execution.RunAuthorization(plan: 'RunPlan', strategy_id: 'str') -> None", + "RunDigest": "class alphalab.lifecycle.reproducibility.RunDigest(result_id: 'str', configuration: 'str', configuration_id: 'str', mode: 'ExecutionMode', seed: 'int | None', dataset_id: 'str | None', strategy_ids: 'tuple[str, ...]', live_objects: 'tuple[str, ...]') -> None", + "RunObservations": "class alphalab.lifecycle.comparison.RunObservations(source: 'ComparisonSource', trades: 'tuple[TradeObservation, ...] | None' = None, fills: 'tuple[FillObservation, ...] | None' = None, realized_pnl: 'CurrencyAmounts | None' = None, exposure: 'CurrencyAmounts | None' = None) -> None", + "RunPlan": "class alphalab.lifecycle.execution.RunPlan(environment: 'str', version: 'StrategyVersion', reference: 'StrategyVersionRef', evidence: 'ValidationEvidence | None', deployed_by: 'str', deployed_at: 'float') -> None", + "RuntimeObservation": "class alphalab.lifecycle.health.RuntimeObservation(observed_at: 'float', last_market_data_at: 'float | None' = None, last_heartbeat_at: 'float | None' = None, broker_connection: 'ConnectionStatus | None' = None, executions: 'tuple[ExecutionObservation, ...] | None' = None, observed_positions: 'Mapping[str, Decimal] | None' = None, expected_positions: 'Mapping[str, Decimal] | None' = None, risk_violations: 'tuple[RiskViolation, ...] | None' = None, state_expectations: 'tuple[StateExpectation, ...] | None' = None) -> None", + "RuntimeProfile": "class alphalab.lifecycle.portability.RuntimeProfile(data_delay_seconds: 'float', heartbeat_interval_seconds: 'float', execution_latency_seconds: 'float') -> None", + "RuntimeRequirements": "class alphalab.lifecycle.specification.RuntimeRequirements(max_data_staleness_seconds: 'float', max_heartbeat_silence_seconds: 'float', max_execution_latency_seconds: 'float', position_tolerance: 'Tolerance') -> None", + "STAGEABLE_MODEL_STAGES": "value builtins.tuple", + "STRATEGY_FINGERPRINT_SCHEME": "value builtins.str", + "STRATEGY_SOURCE_SCHEME": "value builtins.str", + "SeedRole": "enum alphalab.lifecycle.reproducibility.SeedRole[IDENTIFIER_STREAM, STOCHASTIC_STEPS, ABSENT]", + "SourceBytesView": "class alphalab.lifecycle.reproducibility.SourceBytesView(*args, **kwargs)", + "StageTransition": "class alphalab.lifecycle.progression.StageTransition(from_stage: 'StrategyLifecycleStage', to_stage: 'StrategyLifecycleStage', reason: 'str', timestamp: 'float', actor_id: 'str' = '') -> None", + "StateExpectation": "class alphalab.lifecycle.health.StateExpectation(name: 'str', expected: 'str | None', observed: 'str | None') -> None", + "StateReconciliation": "class alphalab.lifecycle.reconciliation.StateReconciliation(mismatches: 'tuple[Mismatch, ...]' = (), unreconciled: 'tuple[UnreconciledArea, ...]' = (), compared_orders: 'int' = 0, compared_fills: 'int' = 0, compared_positions: 'int' = 0, unassigned: 'tuple[str, ...]' = ()) -> None", + "StaticPermissions": "class alphalab.lifecycle.governance.StaticPermissions(grants: 'Mapping[str, frozenset[str]]') -> None", + "StrategyFingerprint": "class alphalab.lifecycle.fingerprint.StrategyFingerprint(fingerprint: 'str', name: 'str', strategy_id: 'str', code: 'CodeIdentity', dependencies: 'DependencyManifest', parameters: 'Mapping[str, ParamValue]', research: 'ResearchConfiguration', engine: 'EngineIdentity') -> None", + "StrategyLifecycleStage": "enum alphalab.lifecycle.progression.StrategyLifecycleStage[RESEARCH, BACKTEST, VALIDATION, PAPER, PRODUCTION_CANDIDATE, LIVE, PAUSED, ARCHIVED]", + "StrategyProgression": "class alphalab.lifecycle.progression.StrategyProgression(reference: 'StrategyVersionRef', stage: 'StrategyLifecycleStage', history: 'AppendOnlyLog[StageTransition]') -> None", + "StrategyPromotionRecord": "class alphalab.lifecycle.strategy_version.StrategyPromotionRecord(name: 'str', version: 'int', from_stage: 'ModelStage', to_stage: 'ModelStage', reason: 'str', timestamp: 'float', actor_id: 'str' = '') -> None", + "StrategyVersion": "class alphalab.lifecycle.strategy_version.StrategyVersion(name: 'str', version: 'int', definition: 'StrategyDefinition', stage: 'ModelStage', model: 'ModelRef | None' = None, run_id: 'str | None' = None, evidence_id: 'str | None' = None, policy_id: 'str | None' = None, created_at: 'float' = 0.0, tags: 'Mapping[str, str]' = ) -> None", + "StrategyVersionRef": "class alphalab.lifecycle.identity.StrategyVersionRef(name: 'str', version: 'int') -> None", + "StrategyVersionRegistry": "class alphalab.lifecycle.strategy_version.StrategyVersionRegistry(versions: 'PersistentMap[str, PersistentMap[int, StrategyVersion]]' = , promotions: 'AppendOnlyLog[StrategyPromotionRecord]' = ) -> None", + "StudyIdentity": "class alphalab.lifecycle.fingerprint.StudyIdentity(*args, **kwargs)", + "SymbolMapping": "class alphalab.lifecycle.reconciliation.SymbolMapping(mapping: 'Mapping[str, str]' = , assume_identity: 'bool' = False) -> None", + "TargetEnvironment": "class alphalab.lifecycle.portability.TargetEnvironment(name: 'str', mode: 'ExecutionMode', broker: 'BrokerCapabilities', market: 'MarketAvailability', datasets: 'frozenset[str] | None' = None, conventions: 'Mapping[str, MarketConvention] | None' = None, runtime: 'RuntimeProfile | None' = None, max_leverage: 'Decimal | None' = None, account_currencies: 'frozenset[str] | None' = None) -> None", + "ThreeWayComparison": "class alphalab.lifecycle.comparison.ThreeWayComparison(expected_vs_paper: 'PairComparison', expected_vs_live: 'PairComparison', paper_vs_live: 'PairComparison') -> None", + "Tolerance": "class alphalab.lifecycle.tolerance.Tolerance(absolute: 'Decimal | None' = None, relative: 'Decimal | None' = None) -> None", + "ToleranceOutcome": "enum alphalab.lifecycle.tolerance.ToleranceOutcome[EXACT, WITHIN_TOLERANCE, MATERIAL]", + "TradeObservation": "class alphalab.lifecycle.comparison.TradeObservation(key: 'str', asset_id: 'str', side: 'Side', quantity: 'Decimal', price: 'Decimal', executed_at: 'float') -> None", + "UNDECLARED_DEPENDENCIES": "value alphalab.lifecycle.fingerprint.DependencyManifest", + "UnevaluatedCategory": "class alphalab.lifecycle.health.UnevaluatedCategory(category: 'HealthCategory', reason: 'str') -> None", + "UnreconciledArea": "class alphalab.lifecycle.reconciliation.UnreconciledArea(area: 'str', reason: 'str') -> None", + "ValidationEvidence": "class alphalab.lifecycle.evidence.ValidationEvidence(evidence_id: 'str', method: 'ValidationMethod', subject: 'str', dataset_id: 'str', metrics: 'Mapping[str, float]' = , seed: 'int | None' = None, produced_at: 'float' = 0.0, source_id: 'str' = '') -> None", + "ValidationMethod": "enum alphalab.lifecycle.evidence.ValidationMethod[BACKTEST, RESEARCH, EXTERNAL, STUDY]", + "ValidationOutcome": "class alphalab.lifecycle.evidence.ValidationOutcome(policy_id: 'str', evidence_id: 'str', passed: 'bool', failures: 'tuple[str, ...]' = ()) -> None", + "ValidationPolicy": "class alphalab.lifecycle.evidence.ValidationPolicy(policy_id: 'str', thresholds: 'tuple[MetricThreshold, ...]' = (), required_method: 'ValidationMethod | None' = None) -> None", + "VersionedDataset": "class alphalab.lifecycle.reproducibility.VersionedDataset(*args, **kwargs)", + "active_model_version": "function alphalab.lifecycle.views.active_model_version(state: 'LifecycleState', environment: 'str') -> 'ModelRef | None'", + "active_strategy_version": "function alphalab.lifecycle.views.active_strategy_version(state: 'LifecycleState', environment: 'str') -> 'StrategyVersion | None'", + "advance_progression": "function alphalab.lifecycle.progression.advance_progression(progression: 'StrategyProgression', target: 'StrategyLifecycleStage', reason: 'str', timestamp: 'float', actor_id: 'str' = '') -> 'StrategyProgression'", + "approval_for": "function alphalab.lifecycle.governance.approval_for(approvals: 'Iterable[ApprovalRecord]', name: 'str', version: 'int', environment: 'str', deployer_id: 'str') -> 'ApprovalRecord'", + "approvals_for": "function alphalab.lifecycle.governance.approvals_for(approvals: 'Iterable[ApprovalRecord]', name: 'str', version: 'int', environment: 'str') -> 'tuple[ApprovalRecord, ...]'", + "approvals_of": "function alphalab.lifecycle.views.approvals_of(state: 'LifecycleState', name: 'str', version: 'int', environment: 'str') -> 'tuple[ApprovalRecord, ...]'", + "approve_deployment": "function alphalab.lifecycle.promotion.approve_deployment(state: 'LifecycleState', governance: 'Governance', name: 'str', version: 'int', environment: 'str', timestamp: 'float', note: 'str' = '') -> 'LifecycleState'", + "archive_progression": "function alphalab.lifecycle.progression.archive_progression(progression: 'StrategyProgression', reason: 'str', timestamp: 'float', actor_id: 'str' = '') -> 'StrategyProgression'", + "assess_adaptive_replay": "function alphalab.lifecycle.reproducibility.assess_adaptive_replay(original: 'AdaptiveReplay', rerun: 'AdaptiveReplay | None' = None) -> 'AdaptiveReplayAssessment'", + "assess_reproducibility": "function alphalab.lifecycle.reproducibility.assess_reproducibility(manifest: 'ReproducibilityManifest', rerun: 'ReproducibilityManifest | None' = None) -> 'ReproducibilityAssessment'", + "authorize_run": "function alphalab.lifecycle.execution.authorize_run(state: 'LifecycleState', environment: 'str', reference: 'StrategyVersionRef', strategy_id: 'str') -> 'RunAuthorization'", + "begin_progression": "function alphalab.lifecycle.progression.begin_progression(reference: 'StrategyVersionRef') -> 'StrategyProgression'", + "broker_capabilities_from": "function alphalab.lifecycle.specification.broker_capabilities_from(declaration: 'CapabilityDeclaration', *, listing_venues: 'frozenset[str]', asset_classes: 'frozenset[AssetType]', account_id: 'str') -> 'BrokerCapabilities'", + "build_evidence": "function alphalab.lifecycle.evidence.build_evidence(method: 'ValidationMethod', subject: 'str', dataset_id: 'str', metrics: 'Mapping[str, float]', produced_at: 'float', seed: 'int | None' = None, source_id: 'str' = '') -> 'ValidationEvidence'", + "build_fingerprint": "function alphalab.lifecycle.fingerprint.build_fingerprint(name: 'str', strategy_id: 'str', code: 'CodeIdentity', dependencies: 'DependencyManifest', parameters: 'Mapping[str, ParamValue]', research: 'ResearchConfiguration', engine: 'EngineIdentity') -> 'StrategyFingerprint'", + "build_specification": "function alphalab.lifecycle.specification.build_specification(strategy: 'StrategyVersionRef', parameters: 'Mapping[str, ParamValue]', datasets: 'Sequence[DatasetAssumption]', risk: 'RiskLimits', capital: 'CapitalPolicy', broker: 'BrokerRequirements', market: 'MarketRequirements', runtime: 'RuntimeRequirements') -> 'DeploymentSpecification'", + "canonical_fingerprint_key": "function alphalab.lifecycle.fingerprint.canonical_fingerprint_key(name: 'str', strategy_id: 'str', code: 'CodeIdentity', dependencies: 'DependencyManifest', parameters: 'Mapping[str, ParamValue]', research: 'ResearchConfiguration', engine: 'EngineIdentity') -> 'str'", + "canonical_manifest_key": "function alphalab.lifecycle.reproducibility.canonical_manifest_key(kind: 'ResultKind', result_id: 'str', dataset_version: 'str', dataset_content_hash: 'str', configuration_id: 'str', seed: 'int | None', seed_role: 'SeedRole', engine: 'EngineIdentity', fingerprint: 'str | None', build: 'EngineBuild | None' = None, schedule_digest: 'str | None' = None, dataset_parts: 'tuple[tuple[str, str], ...]' = (), execution_plan_id: 'str | None' = None, queue_model: 'str | None' = None) -> 'str'", + "certify_strategy": "function alphalab.lifecycle.certification.certify_strategy(fingerprint: 'StrategyFingerprint', specification: 'DeploymentSpecification', evidence: 'CertificationEvidence', budgets: 'Sequence[ResourceBudget]' = (), progression: 'StrategyProgression | None' = None) -> 'CertificationReport'", + "code_identity_for": "function alphalab.lifecycle.fingerprint.code_identity_for(registration: 'StrategyRegistration', package: 'str', version: 'str | None', sources: 'Mapping[str, bytes] | None') -> 'CodeIdentity'", + "compare_expected_paper_live": "function alphalab.lifecycle.comparison.compare_expected_paper_live(expected: 'RunObservations', paper: 'RunObservations', live: 'RunObservations', tolerances: 'Mapping[ComparisonMetric, Tolerance]') -> 'ThreeWayComparison'", + "compare_runs": "function alphalab.lifecycle.comparison.compare_runs(left: 'RunObservations', right: 'RunObservations', tolerances: 'Mapping[ComparisonMetric, Tolerance]') -> 'PairComparison'", + "dataset_assumption_from": "function alphalab.lifecycle.specification.dataset_assumption_from(dataset: 'Dataset', role: 'str') -> 'DatasetAssumption'", + "deploy_strategy_version": "function alphalab.lifecycle.deployment.deploy_strategy_version(state: 'LifecycleState', governance: 'Governance', name: 'str', version: 'int', environment: 'str', timestamp: 'float', deployed_by: 'str' = '', notes: 'str' = '') -> 'tuple[LifecycleState, DeploymentRef]'", + "derive_manifest_id": "function alphalab.lifecycle.reproducibility.derive_manifest_id(kind: 'ResultKind', result_id: 'str', dataset_version: 'str', dataset_content_hash: 'str', configuration_id: 'str', seed: 'int | None', seed_role: 'SeedRole', engine: 'EngineIdentity', fingerprint: 'str | None', build: 'EngineBuild | None' = None, schedule_digest: 'str | None' = None, dataset_parts: 'tuple[tuple[str, str], ...]' = (), execution_plan_id: 'str | None' = None, queue_model: 'str | None' = None) -> 'str'", + "derive_strategy_fingerprint": "function alphalab.lifecycle.fingerprint.derive_strategy_fingerprint(name: 'str', strategy_id: 'str', code: 'CodeIdentity', dependencies: 'DependencyManifest', parameters: 'Mapping[str, ParamValue]', research: 'ResearchConfiguration', engine: 'EngineIdentity') -> 'str'", + "differing_parameters": "function alphalab.lifecycle.fingerprint.differing_parameters(left: 'Mapping[str, ParamValue]', right: 'Mapping[str, ParamValue]') -> 'tuple[str, ...]'", + "digest_run": "function alphalab.lifecycle.reproducibility.digest_run(result: 'BacktestResult') -> 'RunDigest'", + "engine_source_digest": "function alphalab.lifecycle.environment.engine_source_digest() -> 'str'", + "environments_running": "function alphalab.lifecycle.views.environments_running(state: 'LifecycleState', name: 'str', version: 'int') -> 'tuple[str, ...]'", + "evaluate_health": "function alphalab.lifecycle.health.evaluate_health(specification: 'DeploymentSpecification', observation: 'RuntimeObservation') -> 'HealthReport'", + "evaluate_health_window": "function alphalab.lifecycle.health.evaluate_health_window(specification: 'DeploymentSpecification', observations: 'Sequence[RuntimeObservation]', *, as_of: 'float', window_seconds: 'float') -> 'HealthWindowReport'", + "evaluate_policy": "function alphalab.lifecycle.evidence.evaluate_policy(policy: 'ValidationPolicy', evidence: 'ValidationEvidence') -> 'ValidationOutcome'", + "evaluate_portability": "function alphalab.lifecycle.portability.evaluate_portability(fingerprint: 'StrategyFingerprint', specification: 'DeploymentSpecification', environments: 'Sequence[TargetEnvironment]', conventions: 'Mapping[str, MarketConvention] | None' = None) -> 'PortabilityReport'", + "evidence_for": "function alphalab.lifecycle.views.evidence_for(state: 'LifecycleState', name: 'str', version: 'int') -> 'ValidationEvidence | None'", + "evidence_from_backtest": "function alphalab.lifecycle.evidence.evidence_from_backtest(result: 'BacktestResult', subject: 'str', produced_at: 'float') -> 'ValidationEvidence'", + "evidence_from_research": "function alphalab.lifecycle.evidence.evidence_from_research(state: 'ResearchState', subject: 'str', dataset_id: 'str', produced_at: 'float') -> 'ValidationEvidence'", + "evidence_from_study": "function alphalab.lifecycle.evidence.evidence_from_study(result: 'StudyResult', subject: 'str', produced_at: 'float') -> 'ValidationEvidence'", + "evidence_id_for": "function alphalab.lifecycle.evidence.evidence_id_for(method: 'ValidationMethod', subject: 'str', dataset_id: 'str', seed: 'int | None', metrics: 'Mapping[str, float]') -> 'str'", + "external_requirements": "function alphalab.lifecycle.reproducibility.external_requirements(manifest: 'ReproducibilityManifest') -> 'tuple[ExternalRequirement, ...]'", + "fingerprint_differences": "function alphalab.lifecycle.fingerprint.fingerprint_differences(left: 'StrategyFingerprint', right: 'StrategyFingerprint') -> 'tuple[str, ...]'", + "fingerprint_for_version": "function alphalab.lifecycle.fingerprint.fingerprint_for_version(version: 'StrategyVersion', code: 'CodeIdentity', dependencies: 'DependencyManifest', research: 'ResearchConfiguration', engine: 'EngineIdentity') -> 'StrategyFingerprint'", + "get_strategy_version": "function alphalab.lifecycle.strategy_version.get_strategy_version(registry: 'StrategyVersionRegistry', name: 'str', version: 'int') -> 'StrategyVersion'", + "governance_log": "function alphalab.lifecycle.views.governance_log(state: 'LifecycleState') -> 'tuple[GovernedAct, ...]'", + "illegal_progression_move": "function alphalab.lifecycle.progression.illegal_progression_move(progression: 'StrategyProgression', target: 'StrategyLifecycleStage') -> 'str | None'", + "latest_strategy_version": "function alphalab.lifecycle.strategy_version.latest_strategy_version(registry: 'StrategyVersionRegistry', name: 'str') -> 'StrategyVersion'", + "list_strategy_versions": "function alphalab.lifecycle.strategy_version.list_strategy_versions(registry: 'StrategyVersionRegistry', name: 'str') -> 'tuple[StrategyVersion, ...]'", + "live_environments": "function alphalab.lifecycle.views.live_environments(state: 'LifecycleState') -> 'tuple[str, ...]'", + "manifest_for_run": "function alphalab.lifecycle.reproducibility.manifest_for_run(result: 'BacktestResult', dataset: 'VersionedDataset', fingerprint: 'StrategyFingerprint', engine: 'EngineIdentity', *, build: 'EngineBuild | None', schedule: 'RunSchedule | None' = None, execution: 'ExecutionPlan | None' = None) -> 'ReproducibilityManifest'", + "manifest_for_study": "function alphalab.lifecycle.reproducibility.manifest_for_study(result: 'StudyResult', dataset: 'VersionedDataset', engine: 'EngineIdentity', fingerprint: 'StrategyFingerprint | None' = None, *, build: 'EngineBuild | None') -> 'ReproducibilityManifest'", + "manifest_gaps": "function alphalab.lifecycle.reproducibility.manifest_gaps(manifest: 'ReproducibilityManifest') -> 'tuple[str, ...]'", + "normalize_distribution_name": "function alphalab.lifecycle.fingerprint.normalize_distribution_name(name: 'str') -> 'str'", + "observation_from_live_run": "function alphalab.lifecycle.health.observation_from_live_run(state: 'object', observed_at: 'float', last_market_data_at: 'float | None' = None, expected_positions: 'Mapping[str, Decimal] | None' = None, risk_violations: 'tuple[RiskViolation, ...] | None' = None, state_expectations: 'tuple[StateExpectation, ...] | None' = None, submitted_at: 'Mapping[str, float] | None' = None) -> 'RuntimeObservation'", + "observations_from_backtest": "function alphalab.lifecycle.comparison.observations_from_backtest(result: 'BacktestResult', source: 'ComparisonSource', alignment: 'AlignmentKey', exposure: 'CurrencyAmounts | None' = None, submitted_at: 'Mapping[str, float] | None' = None) -> 'RunObservations'", + "observations_from_broker": "function alphalab.lifecycle.comparison.observations_from_broker(state: 'BrokerState', source: 'ComparisonSource', alignment: 'AlignmentKey', realized_pnl: 'CurrencyAmounts | None' = None, exposure: 'CurrencyAmounts | None' = None, submitted_at: 'Mapping[str, float] | None' = None) -> 'RunObservations'", + "parse_ref": "function alphalab.lifecycle.identity.parse_ref(reference: 'str') -> 'tuple[str, int]'", + "pause_progression": "function alphalab.lifecycle.progression.pause_progression(progression: 'StrategyProgression', reason: 'str', timestamp: 'float', actor_id: 'str' = '') -> 'StrategyProgression'", + "progression_conflicts": "function alphalab.lifecycle.progression.progression_conflicts(progression: 'StrategyProgression', version: 'StrategyVersion') -> 'tuple[str, ...]'", + "promote_strategy_version": "function alphalab.lifecycle.promotion.promote_strategy_version(state: 'LifecycleState', governance: 'Governance', name: 'str', version: 'int', policy: 'ValidationPolicy', evidence_id: 'str', timestamp: 'float') -> 'LifecycleState'", + "read_lock_file": "function alphalab.lifecycle.lockfile.read_lock_file(text: 'str', lock_format: 'LockFormat', *, exclude: 'Iterable[str]' = ()) -> 'DependencyManifest'", + "reconcile_accounts": "function alphalab.lifecycle.reconciliation.reconcile_accounts(pipeline: 'ExecutionPipelineState', accounts: 'Mapping[str, AccountMirror]', assignment: 'Mapping[str, str]', tolerances: 'ReconciliationTolerances', *, cash_by_currency: 'Mapping[str, Tolerance] | None' = None) -> 'StateReconciliation'", + "reconcile_execution_state": "function alphalab.lifecycle.reconciliation.reconcile_execution_state(pipeline: 'ExecutionPipelineState', broker: 'BrokerState', mapping: 'ExternalOrderMap', symbols: 'SymbolMapping', tolerances: 'ReconciliationTolerances', *, children: 'ChildOrderBindings | None' = None) -> 'StateReconciliation'", + "record_evidence": "function alphalab.lifecycle.promotion.record_evidence(state: 'LifecycleState', evidence: 'ValidationEvidence') -> 'LifecycleState'", + "record_stage_change": "function alphalab.lifecycle.promotion.record_stage_change(state: 'LifecycleState', strategy: 'StrategyVersion', to_stage: 'ModelStage', reason: 'str', timestamp: 'float', evidence_id: 'str | None' = None, policy_id: 'str | None' = None, actor_id: 'str' = '') -> 'LifecycleState'", + "register_model_version": "function alphalab.lifecycle.registration.register_model_version(state: 'LifecycleState', name: 'str', model: 'object', timestamp: 'float', run_id: 'str | None' = None, metrics: 'Mapping[str, float] | None' = None, parameters: 'Mapping[str, ParamValue] | None' = None, tags: 'Mapping[str, str] | None' = None, artifact: 'ArtifactRef | None' = None) -> 'tuple[LifecycleState, ModelRef]'", + "register_strategy": "function alphalab.lifecycle.registration.register_strategy(state: 'LifecycleState', name: 'str', definition: 'StrategyDefinition', timestamp: 'float', model: 'ModelRef | None' = None, run_id: 'str | None' = None, tags: 'Mapping[str, str] | None' = None) -> 'tuple[LifecycleState, StrategyVersionRef]'", + "register_strategy_version": "function alphalab.lifecycle.strategy_version.register_strategy_version(registry: 'StrategyVersionRegistry', name: 'str', definition: 'StrategyDefinition', timestamp: 'float', model: 'ModelRef | None' = None, run_id: 'str | None' = None, tags: 'Mapping[str, str] | None' = None) -> 'tuple[StrategyVersionRegistry, StrategyVersion]'", + "release_manifest": "function alphalab.lifecycle.deployment.release_manifest(strategy: 'StrategyVersion') -> 'tuple[dict[str, str], dict[str, str]]'", + "replace_strategy_version": "function alphalab.lifecycle.strategy_version.replace_strategy_version(registry: 'StrategyVersionRegistry', updated: 'StrategyVersion') -> 'StrategyVersionRegistry'", + "rerun_from_manifest": "function alphalab.lifecycle.rerun.rerun_from_manifest(manifest: 'ReproducibilityManifest', run: 'Callable[[], BacktestResult]', *, dataset: 'VersionedDataset', fingerprint: 'StrategyFingerprint', engine: 'EngineIdentity', build: 'EngineBuild | None', original: 'BacktestResult | None' = None) -> 'RerunReport'", + "research_configuration": "function alphalab.lifecycle.fingerprint.research_configuration(settings: 'Mapping[str, str]', study_id: 'str | None' = None) -> 'ResearchConfiguration'", + "research_configuration_for_study": "function alphalab.lifecycle.fingerprint.research_configuration_for_study(study: 'StudyIdentity', settings: 'Mapping[str, str] | None' = None) -> 'ResearchConfiguration'", + "research_configuration_with_adaptive": "function alphalab.lifecycle.fingerprint.research_configuration_with_adaptive(settings: 'Mapping[str, str]', adaptive: 'Sequence[tuple[AdaptiveConfiguration, AdaptiveState]]', study: 'StudyIdentity | None' = None) -> 'ResearchConfiguration'", + "research_configuration_with_execution": "function alphalab.lifecycle.fingerprint.research_configuration_with_execution(settings: 'Mapping[str, str]', *, algorithms: 'Mapping[str, ExecutionAlgorithmIdentity]', routing: 'Mapping[str, RoutingPolicyIdentity]', study: 'StudyIdentity | None') -> 'ResearchConfiguration'", + "research_configuration_with_portfolio": "function alphalab.lifecycle.fingerprint.research_configuration_with_portfolio(settings: 'Mapping[str, str]', *, constructions: 'Mapping[str, ConstructionIdentity]', capital: 'Mapping[str, CapitalPlanIdentity]', study: 'StudyIdentity | None') -> 'ResearchConfiguration'", + "resource_counts": "function alphalab.lifecycle.certification.resource_counts(result: 'BacktestResult') -> 'tuple[ResourceMeasurement, ...]'", + "resume_progression": "function alphalab.lifecycle.progression.resume_progression(progression: 'StrategyProgression', reason: 'str', timestamp: 'float', actor_id: 'str' = '') -> 'StrategyProgression'", + "resume_target": "function alphalab.lifecycle.progression.resume_target(progression: 'StrategyProgression') -> 'StrategyLifecycleStage | None'", + "retire_strategy_version": "function alphalab.lifecycle.promotion.retire_strategy_version(state: 'LifecycleState', governance: 'Governance', name: 'str', version: 'int', timestamp: 'float', reason: 'str' = 'retired') -> 'LifecycleState'", + "rollback_environment": "function alphalab.lifecycle.deployment.rollback_environment(state: 'LifecycleState', governance: 'Governance', environment: 'str', timestamp: 'float', deployed_by: 'str' = '', notes: 'str' = '') -> 'tuple[LifecycleState, DeploymentRef]'", + "run_plan": "function alphalab.lifecycle.execution.run_plan(state: 'LifecycleState', environment: 'str') -> 'RunPlan'", + "running_build": "function alphalab.lifecycle.environment.running_build() -> 'EngineBuild'", + "running_engine": "function alphalab.lifecycle.fingerprint.running_engine() -> 'EngineIdentity'", + "source_digest": "function alphalab.lifecycle.fingerprint.source_digest(sources: 'Mapping[str, bytes]') -> 'str'", + "source_tree_digest": "function alphalab.lifecycle.environment.source_tree_digest(root: 'Path') -> 'str'", + "specification_for_version": "function alphalab.lifecycle.specification.specification_for_version(version: 'StrategyVersion', datasets: 'Sequence[DatasetAssumption]', risk: 'RiskLimits', capital: 'CapitalPolicy', broker: 'BrokerRequirements', market: 'MarketRequirements', runtime: 'RuntimeRequirements') -> 'DeploymentSpecification'", + "specification_id_for": "function alphalab.lifecycle.specification.specification_id_for(strategy: 'StrategyVersionRef', parameters: 'Mapping[str, ParamValue]', datasets: 'Sequence[DatasetAssumption]', risk: 'RiskLimits', capital: 'CapitalPolicy', broker: 'BrokerRequirements', market: 'MarketRequirements', runtime: 'RuntimeRequirements') -> 'str'", + "strategy_names": "function alphalab.lifecycle.strategy_version.strategy_names(registry: 'StrategyVersionRegistry') -> 'tuple[str, ...]'", + "tz_database_version": "function alphalab.lifecycle.environment.tz_database_version() -> 'str | None'", + "unmet_broker_requirements": "function alphalab.lifecycle.specification.unmet_broker_requirements(requirements: 'BrokerRequirements', capabilities: 'BrokerCapabilities') -> 'tuple[str, ...]'", + "unmet_market_requirements": "function alphalab.lifecycle.specification.unmet_market_requirements(requirements: 'MarketRequirements', availability: 'MarketAvailability') -> 'tuple[str, ...]'", + "validate_specification": "function alphalab.lifecycle.specification.validate_specification(specification: 'DeploymentSpecification') -> 'tuple[str, ...]'", + "validate_strategy_version": "function alphalab.lifecycle.promotion.validate_strategy_version(state: 'LifecycleState', name: 'str', version: 'int', policy: 'ValidationPolicy', evidence_id: 'str') -> 'ValidationOutcome'", + "verify_certification_report": "function alphalab.lifecycle.certification.verify_certification_report(report: 'CertificationReport') -> 'bool'", + "verify_evidence_id": "function alphalab.lifecycle.evidence.verify_evidence_id(evidence: 'ValidationEvidence') -> 'bool'", + "verify_fingerprint": "function alphalab.lifecycle.fingerprint.verify_fingerprint(fingerprint: 'StrategyFingerprint') -> 'bool'", + "verify_manifest": "function alphalab.lifecycle.reproducibility.verify_manifest(manifest: 'ReproducibilityManifest') -> 'bool'", + "verify_portability_report": "function alphalab.lifecycle.portability.verify_portability_report(report: 'PortabilityReport') -> 'bool'", + "verify_specification_id": "function alphalab.lifecycle.specification.verify_specification_id(specification: 'DeploymentSpecification') -> 'bool'" + }, + "alphalab.lifecycle.snapshot": { + "LIFECYCLE_SCHEMA_HISTORY": "value alphalab.persistence.upgrade.SchemaHistory", + "LIFECYCLE_SNAPSHOT_SCHEMA": "value builtins.int", + "LifecycleSnapshot": "class alphalab.lifecycle.snapshot.LifecycleSnapshot(runs: 'tuple[ExperimentRun, ...]', model_versions: 'tuple[ModelVersionRecord, ...]', model_promotions: 'tuple[PromotionRecord, ...]', production: 'Mapping[str, int]', production_line: 'Mapping[str, tuple[int, ...]]', strategy_versions: 'tuple[StrategyVersion, ...]', strategy_promotions: 'tuple[StrategyPromotionRecord, ...]', release_packages: 'tuple[ReleasePackage, ...]', deployments: 'tuple[DeploymentRecord, ...]', evidence: 'tuple[ValidationEvidence, ...]', releases: 'Mapping[str, int]', approvals: 'tuple[ApprovalRecord, ...]' = (), schema_version: 'int' = 2) -> None", + "ModelVersionRecord": "class alphalab.lifecycle.snapshot.ModelVersionRecord(name: 'str', version: 'int', model_type: 'str', stage: 'ModelStage', metrics: 'Mapping[str, float]', parameters: 'Mapping[str, Any]', tags: 'Mapping[str, str]', run_id: 'str | None', created_at: 'float', deployment: 'DeploymentMetadata | None', artifact: 'ArtifactRef | None') -> None", + "capture": "function alphalab.lifecycle.snapshot.capture(state: 'LifecycleState') -> 'LifecycleSnapshot'", + "from_primitives": "function alphalab.lifecycle.snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'LifecycleSnapshot'", + "restore": "function alphalab.lifecycle.snapshot.restore(snapshot: 'LifecycleSnapshot', models: 'Mapping[str, object]') -> 'LifecycleState'" + }, + "alphalab.macro": { + "Bond": "class alphalab.macro.bond.Bond(face: 'Decimal', coupon_rate: 'float', frequency: 'Compounding', issue_date: 'date', maturity: 'date', day_count: 'DayCount', currency: 'str') -> None", + "CashFlow": "class alphalab.macro.bond.CashFlow(payment_date: 'date', amount: 'Decimal', is_principal: 'bool') -> None", + "CentralBankEvent": "class alphalab.macro.central_bank.CentralBankEvent(bank_id: str, event_date: float, action: alphalab.macro.enums.PolicyAction, rate_before: decimal.Decimal, rate_after: decimal.Decimal, next_meeting_date: float | None = None) -> None", + "Frequency": "enum alphalab.macro.enums.Frequency[DAILY, WEEKLY, MONTHLY, QUARTERLY, ANNUAL]", + "IndicatorMetadata": "class alphalab.macro.indicator.IndicatorMetadata(indicator_id: str, name: str, country: str, frequency: alphalab.macro.enums.Frequency, units: str, seasonally_adjusted: bool = True) -> None", + "IndicatorObservation": "class alphalab.macro.indicator.IndicatorObservation(indicator_id: str, reference_period: float, release_date: float, value: decimal.Decimal, is_revision: bool = False, consensus_estimate: decimal.Decimal | None = None) -> None", + "MacroComputationError": "class alphalab.macro.exceptions.MacroComputationError", + "MacroError": "class alphalab.macro.exceptions.MacroError", + "MacroInputError": "class alphalab.macro.exceptions.MacroInputError", + "PolicyAction": "enum alphalab.macro.enums.PolicyAction[HIKE, CUT, HOLD]", + "YieldCurve": "class alphalab.macro.yield_curve.YieldCurve(country: str, currency: str, timestamp: float, points: tuple[alphalab.macro.yield_curve.YieldCurvePoint, ...]) -> None", + "YieldCurvePoint": "class alphalab.macro.yield_curve.YieldCurvePoint(tenor_years: decimal.Decimal, yield_rate: decimal.Decimal) -> None", + "accrued_interest": "function alphalab.macro.bond.accrued_interest(bond: 'Bond', settlement: 'date') -> 'Decimal'", + "cash_flows": "function alphalab.macro.bond.cash_flows(bond: 'Bond') -> 'tuple[CashFlow, ...]'", + "clean_price": "function alphalab.macro.bond.clean_price(bond: 'Bond', yield_rate: 'float', settlement: 'date') -> 'Decimal'", + "convexity": "function alphalab.macro.bond.convexity(bond: 'Bond', yield_rate: 'float', settlement: 'date') -> 'float'", + "dirty_price": "function alphalab.macro.bond.dirty_price(bond: 'Bond', yield_rate: 'float', settlement: 'date') -> 'Decimal'", + "discount_factor_at": "function alphalab.macro.yield_curve.discount_factor_at(curve: alphalab.macro.yield_curve.YieldCurve, tenor_years: decimal.Decimal, compounding: alphalab.conventions.rates.Compounding) -> float | None", + "gdp_growth_rate": "function alphalab.macro.gdp.gdp_growth_rate(current_gdp: decimal.Decimal, prior_gdp: decimal.Decimal) -> decimal.Decimal", + "is_inverted": "function alphalab.macro.yield_curve.is_inverted(curve: alphalab.macro.yield_curve.YieldCurve, short_tenor_years: decimal.Decimal = Decimal('2'), long_tenor_years: decimal.Decimal = Decimal('10')) -> bool | None", + "known_as_of": "function alphalab.macro.indicator.known_as_of(observations: tuple[alphalab.macro.indicator.IndicatorObservation, ...], as_of: float) -> alphalab.macro.indicator.IndicatorObservation | None", + "macaulay_duration": "function alphalab.macro.bond.macaulay_duration(bond: 'Bond', yield_rate: 'float', settlement: 'date') -> 'float'", + "modified_duration": "function alphalab.macro.bond.modified_duration(bond: 'Bond', yield_rate: 'float', settlement: 'date') -> 'float'", + "rate_change_bps": "function alphalab.macro.central_bank.rate_change_bps(event: alphalab.macro.central_bank.CentralBankEvent) -> int", + "real_gdp": "function alphalab.macro.gdp.real_gdp(nominal_gdp: decimal.Decimal, gdp_deflator_index: decimal.Decimal) -> decimal.Decimal", + "real_interest_rate_approx": "function alphalab.macro.inflation.real_interest_rate_approx(nominal_rate: decimal.Decimal, inflation_rate: decimal.Decimal) -> decimal.Decimal", + "real_interest_rate_exact": "function alphalab.macro.inflation.real_interest_rate_exact(nominal_rate: decimal.Decimal, inflation_rate: decimal.Decimal) -> decimal.Decimal", + "sorted_by_tenor": "function alphalab.macro.yield_curve.sorted_by_tenor(curve: alphalab.macro.yield_curve.YieldCurve) -> tuple[alphalab.macro.yield_curve.YieldCurvePoint, ...]", + "spread": "function alphalab.macro.yield_curve.spread(curve: alphalab.macro.yield_curve.YieldCurve, short_tenor_years: decimal.Decimal, long_tenor_years: decimal.Decimal) -> decimal.Decimal | None", + "surprise": "function alphalab.macro.indicator.surprise(observation: alphalab.macro.indicator.IndicatorObservation) -> decimal.Decimal | None", + "three_month_ten_year_spread": "function alphalab.macro.yield_curve.three_month_ten_year_spread(curve: alphalab.macro.yield_curve.YieldCurve) -> decimal.Decimal | None", + "two_year_ten_year_spread": "function alphalab.macro.yield_curve.two_year_ten_year_spread(curve: alphalab.macro.yield_curve.YieldCurve) -> decimal.Decimal | None", + "yield_at_tenor": "function alphalab.macro.yield_curve.yield_at_tenor(curve: alphalab.macro.yield_curve.YieldCurve, tenor_years: decimal.Decimal) -> decimal.Decimal | None", + "yield_from_clean_price": "function alphalab.macro.bond.yield_from_clean_price(bond: 'Bond', price: 'Decimal', settlement: 'date') -> 'float'" + }, + "alphalab.market": { + "Bar": "class alphalab.market.bar.Bar(asset_id: 'str', timestamp: 'float', open: 'Decimal', high: 'Decimal', low: 'Decimal', close: 'Decimal', volume: 'Decimal', vwap: 'Decimal | None', trade_count: 'int | None', timeframe: 'TimeFrame') -> None", + "BarClosed": "class alphalab.market.events.BarClosed(event_id: str, timestamp: float, bar: alphalab.market.bar.Bar) -> None", + "BarHistoryProvider": "class alphalab.market.provider.BarHistoryProvider(*args, **kwargs)", + "BookUpdated": "class alphalab.market.events.BookUpdated(event_id: str, timestamp: float, snapshot: alphalab.market.snapshot.OrderBookSnapshot) -> None", + "IdentityResolution": "alias alphalab.instrument.registry.InstrumentRegistry | alphalab.market.normalization.UnresolvedIdentity", + "InstrumentResolutionError": "class alphalab.market.exceptions.InstrumentResolutionError", + "MarketDataError": "class alphalab.market.exceptions.MarketDataError", + "MarketDataSource": "class alphalab.market.source.MarketDataSource(*args, **kwargs)", + "MarketEngine": "class alphalab.market.engine.MarketEngine()", + "MarketEvent": "class alphalab.market.events.MarketEvent(event_id: str, timestamp: float) -> None", + "MarketInput": "alias alphalab.market.quote.Quote | alphalab.market.bar.Bar | alphalab.market.tick.Tick", + "MarketRecord": "class alphalab.market.record.MarketRecord(event_id: 'str', timestamp: 'float', payload: 'MarketInput') -> None", + "MarketState": "class alphalab.market.state.MarketState(latest_quotes: alphalab.common.persistent_map.PersistentMap[str, alphalab.market.quote.Quote] = , latest_books: alphalab.common.persistent_map.PersistentMap[str, alphalab.market.snapshot.OrderBookSnapshot] = , latest_ticks: alphalab.common.persistent_map.PersistentMap[str, alphalab.market.tick.Tick] = , latest_bars: alphalab.common.persistent_map.PersistentMap[str, alphalab.market.bar.Bar] = , history: alphalab.common.append_log.AppendOnlyLog[alphalab.market.events.MarketEvent] = , events: alphalab.common.append_log.AppendOnlyLog[alphalab.market.events.MarketEvent] = ) -> None", + "MarketValidationError": "class alphalab.market.exceptions.MarketValidationError", + "NormalizationPolicy": "class alphalab.market.normalization.NormalizationPolicy(venue: 'str' = 'UNKNOWN', currency: 'str | None' = None, timeframe: 'TimeFrame | None' = None, identity: 'IdentityResolution' = UnresolvedIdentity(symbols=SymbolMap(mapping={})), provider: 'str' = '', bar_stamp: 'BarStamp | None' = None) -> None", + "OrderBookLevel": "class alphalab.market.level.OrderBookLevel(price: decimal.Decimal, size: decimal.Decimal, orders: int) -> None", + "OrderBookSnapshot": "class alphalab.market.snapshot.OrderBookSnapshot(asset_id: str, timestamp: float, bids: tuple[alphalab.market.level.OrderBookLevel, ...], asks: tuple[alphalab.market.level.OrderBookLevel, ...], sequence: int) -> None", + "OrderingGuarantee": "enum alphalab.market.source.OrderingGuarantee[CHRONOLOGICAL, UNORDERED]", + "ProviderHistorySource": "class alphalab.market.provider.ProviderHistorySource(source_id: 'str', _records: 'tuple[MarketRecord, ...]', ordering: 'OrderingGuarantee' = ) -> None", + "Quote": "class alphalab.market.quote.Quote(asset_id: str, timestamp: float, bid: decimal.Decimal, ask: decimal.Decimal, bid_size: decimal.Decimal, ask_size: decimal.Decimal, venue: str, currency: str) -> None", + "QuoteReceived": "class alphalab.market.events.QuoteReceived(event_id: str, timestamp: float, quote: alphalab.market.quote.Quote) -> None", + "SequenceSource": "class alphalab.market.source.SequenceSource(source_id: 'str', _records: 'tuple[MarketRecord, ...]', ordering: 'OrderingGuarantee' = ) -> None", + "SnapshotCreated": "class alphalab.market.events.SnapshotCreated(event_id: str, timestamp: float, snapshot: alphalab.market.snapshot.OrderBookSnapshot) -> None", + "SymbolMap": "class alphalab.market.normalization.SymbolMap(mapping: 'Mapping[str, str]' = ) -> None", + "Tick": "class alphalab.market.tick.Tick(asset_id: str, timestamp: float, price: decimal.Decimal, quantity: decimal.Decimal, trade_id: str, venue: str, currency: str, aggressor: alphalab.data.feed.TradeAggressor | None = None) -> None", + "TickReceived": "class alphalab.market.events.TickReceived(event_id: str, timestamp: float, tick: alphalab.market.tick.Tick) -> None", + "TimeFrame": "class alphalab.market.bar.TimeFrame(count: 'int', unit: 'IntervalUnit') -> None", + "TradeReceived": "class alphalab.market.events.TradeReceived(event_id: str, timestamp: float, tick: alphalab.market.tick.Tick) -> None", + "UNRESOLVED_IDENTITY": "value alphalab.market.normalization.UnresolvedIdentity", + "UnresolvedIdentity": "class alphalab.market.normalization.UnresolvedIdentity(symbols: 'SymbolMap' = ) -> None", + "UnsupportedRecordError": "class alphalab.market.exceptions.UnsupportedRecordError", + "bars": "function alphalab.market.views.bars(state: alphalab.market.state.MarketState) -> collections.abc.Sequence[alphalab.market.bar.Bar]", + "best_ask": "function alphalab.market.book.best_ask(snapshot: alphalab.market.snapshot.OrderBookSnapshot) -> decimal.Decimal", + "best_bid": "function alphalab.market.book.best_bid(snapshot: alphalab.market.snapshot.OrderBookSnapshot) -> decimal.Decimal", + "books": "function alphalab.market.views.books(state: alphalab.market.state.MarketState) -> collections.abc.Sequence[alphalab.market.snapshot.OrderBookSnapshot]", + "is_stale": "function alphalab.market.normalization.is_stale(timestamp: 'float', now: 'float', max_age_seconds: 'float') -> 'bool'", + "is_valid_timestamp": "function alphalab.market.timestamp.is_valid_timestamp(timestamp: float) -> bool", + "latest_bar": "function alphalab.market.views.latest_bar(state: alphalab.market.state.MarketState, asset_id: str, timeframe: str) -> alphalab.market.bar.Bar | None", + "latest_book": "function alphalab.market.views.latest_book(state: alphalab.market.state.MarketState, asset_id: str) -> alphalab.market.snapshot.OrderBookSnapshot | None", + "latest_quote": "function alphalab.market.views.latest_quote(state: alphalab.market.state.MarketState, asset_id: str) -> alphalab.market.quote.Quote | None", + "latest_tick": "function alphalab.market.views.latest_tick(state: alphalab.market.state.MarketState, asset_id: str) -> alphalab.market.tick.Tick | None", + "mid_price": "function alphalab.market.book.mid_price(snapshot: alphalab.market.snapshot.OrderBookSnapshot) -> decimal.Decimal", + "normalize_wire_bar": "function alphalab.market.normalization.normalize_wire_bar(bar: 'WireBar', policy: 'NormalizationPolicy') -> 'Bar'", + "normalize_wire_bars": "function alphalab.market.provider.normalize_wire_bars(bars: 'Sequence[WireBar]', policy: 'NormalizationPolicy') -> 'tuple[Bar, ...]'", + "normalize_wire_book": "function alphalab.market.normalization.normalize_wire_book(book: 'WireOrderBook', policy: 'NormalizationPolicy', sequence: 'int' = 1) -> 'OrderBookSnapshot'", + "normalize_wire_quote": "function alphalab.market.normalization.normalize_wire_quote(quote: 'WireQuote', policy: 'NormalizationPolicy') -> 'Quote'", + "normalize_wire_trade": "function alphalab.market.normalization.normalize_wire_trade(trade: 'WireTrade', policy: 'NormalizationPolicy', trade_id: 'str' = '') -> 'Tick'", + "quotes": "function alphalab.market.views.quotes(state: alphalab.market.state.MarketState) -> collections.abc.Sequence[alphalab.market.quote.Quote]", + "records_from_inputs": "function alphalab.market.record.records_from_inputs(stream_id: 'str', inputs: 'Sequence[MarketInput]') -> 'tuple[MarketRecord, ...]'", + "reject_stale": "function alphalab.market.normalization.reject_stale(timestamp: 'float', now: 'float', max_age_seconds: 'float', what: 'str') -> 'None'", + "spread": "function alphalab.market.book.spread(snapshot: alphalab.market.snapshot.OrderBookSnapshot) -> decimal.Decimal", + "ticks": "function alphalab.market.views.ticks(state: alphalab.market.state.MarketState) -> collections.abc.Sequence[alphalab.market.tick.Tick]", + "to_decimal": "function alphalab.market.normalization.to_decimal(value: 'float | str | Decimal') -> 'Decimal'", + "to_unix_milliseconds": "function alphalab.market.timestamp.to_unix_milliseconds(seconds: float) -> int", + "to_unix_seconds": "function alphalab.market.timestamp.to_unix_seconds(milliseconds: int) -> float", + "validate_bar": "function alphalab.market.validation.validate_bar(bar: alphalab.market.bar.Bar) -> None", + "validate_ordering": "function alphalab.market.source.validate_ordering(records: 'Sequence[MarketRecord]') -> 'None'", + "validate_quote": "function alphalab.market.validation.validate_quote(quote: alphalab.market.quote.Quote) -> None", + "validate_snapshot": "function alphalab.market.validation.validate_snapshot(snapshot: alphalab.market.snapshot.OrderBookSnapshot) -> None", + "validate_tick": "function alphalab.market.validation.validate_tick(tick: alphalab.market.tick.Tick) -> None", + "weighted_mid": "function alphalab.market.book.weighted_mid(snapshot: alphalab.market.snapshot.OrderBookSnapshot) -> decimal.Decimal" + }, + "alphalab.marketdata": { + "Bar": "class alphalab.data.feed.Bar(symbol: str, timestamp: float, open: float, high: float, low: float, close: float, volume: float) -> None", + "HttpTransport": "class alphalab.marketdata.transport.HttpTransport(timeout_seconds: float = 10.0) -> None", + "MarketDataError": "class alphalab.marketdata.exceptions.MarketDataError", + "OrderBook": "class alphalab.data.feed.OrderBook(symbol: str, timestamp: float, bids: tuple[alphalab.data.feed.OrderBookLevel, ...] = , asks: tuple[alphalab.data.feed.OrderBookLevel, ...] = ) -> None", + "OrderBookLevel": "class alphalab.data.feed.OrderBookLevel(price: float, size: float) -> None", + "Quote": "class alphalab.data.feed.Quote(symbol: str, timestamp: float, bid: float, ask: float, bid_size: float, ask_size: float) -> None", + "StaticTransport": "class alphalab.marketdata.transport.StaticTransport(responses: collections.abc.Mapping[str, bytes] = , default_response: bytes = b'{}') -> None", + "Trade": "class alphalab.data.feed.Trade(symbol: str, timestamp: float, price: float, size: float, trade_id: str = '', aggressor: alphalab.data.feed.TradeAggressor | None = None) -> None", + "Transport": "class alphalab.marketdata.transport.Transport(*args, **kwargs)", + "WebSocketConnection": "class alphalab.marketdata.websocket.WebSocketConnection(url: 'str', _socket: 'socket.socket', _buffer: 'bytearray' = , _closed: 'bool' = False, last_pong: 'bytes' = b'', _fragments: 'list[bytes]' = , _fragment_opcode: '_Opcode' = <_Opcode.TEXT: 1>, _released: 'bool' = False) -> None", + "WebSocketError": "class alphalab.marketdata.websocket.WebSocketError", + "WebSocketTransport": "class alphalab.marketdata.websocket.WebSocketTransport(headers: 'Mapping[str, str] | None' = None, timeout_seconds: 'float' = 10.0) -> 'None'", + "connect_websocket": "function alphalab.marketdata.websocket.connect_websocket(url: 'str', *, headers: 'Mapping[str, str] | None' = None, timeout_seconds: 'float' = 10.0) -> 'WebSocketConnection'" + }, + "alphalab.ml": { + "ConfusionMatrix": "class alphalab.ml.evaluation.ConfusionMatrix(true_positives: int, true_negatives: int, false_positives: int, false_negatives: int) -> None", + "Dataset": "class alphalab.ml.dataset.Dataset(feature_names: tuple[str, ...], x: tuple[tuple[float, ...], ...], y: tuple[float, ...], asset_ids: tuple[str, ...]) -> None", + "FeatureSpec": "alias tuple[str, int]", + "LinearRegressionModel": "class alphalab.ml.linear_regression.LinearRegressionModel(feature_names: tuple[str, ...], coefficients: tuple[float, ...], intercept: float, l2_penalty: float = 0.0) -> None", + "LogisticRegressionModel": "class alphalab.ml.logistic_regression.LogisticRegressionModel(feature_names: tuple[str, ...], weights: tuple[float, ...], bias: float, iterations: int, learning_rate: float) -> None", + "MLComputationError": "class alphalab.ml.exceptions.MLComputationError", + "MLError": "class alphalab.ml.exceptions.MLError", + "MLInputError": "class alphalab.ml.exceptions.MLInputError", + "Matrix": "alias tuple[tuple[float, ...], ...]", + "TrainTestSplit": "alias tuple[tuple[int, ...], tuple[int, ...]]", + "accuracy": "function alphalab.ml.evaluation.accuracy(y_true: tuple[int, ...], y_pred: tuple[int, ...]) -> float", + "build_dataset_from_feature_store": "function alphalab.ml.dataset.build_dataset_from_feature_store(state: alphalab.feature_store.state.FeatureStoreState, feature_specs: tuple[tuple[str, int], ...], target_spec: tuple[str, int], asset_ids: tuple[str, ...]) -> alphalab.ml.dataset.Dataset", + "confusion_matrix": "function alphalab.ml.evaluation.confusion_matrix(y_true: tuple[int, ...], y_pred: tuple[int, ...]) -> alphalab.ml.evaluation.ConfusionMatrix", + "f1_score": "function alphalab.ml.evaluation.f1_score(y_true: tuple[int, ...], y_pred: tuple[int, ...]) -> float", + "identity": "function alphalab.ml.linalg.identity(n: int) -> tuple[tuple[float, ...], ...]", + "k_fold_split": "function alphalab.ml.cross_validation.k_fold_split(n_samples: int, k: int) -> tuple[tuple[tuple[int, ...], tuple[int, ...]], ...]", + "matmul": "function alphalab.ml.linalg.matmul(a: tuple[tuple[float, ...], ...], b: tuple[tuple[float, ...], ...]) -> tuple[tuple[float, ...], ...]", + "matrix_inverse": "function alphalab.ml.linalg.matrix_inverse(matrix: tuple[tuple[float, ...], ...]) -> tuple[tuple[float, ...], ...]", + "mean_absolute_error": "function alphalab.ml.evaluation.mean_absolute_error(y_true: tuple[float, ...], y_pred: tuple[float, ...]) -> float", + "mean_squared_error": "function alphalab.ml.evaluation.mean_squared_error(y_true: tuple[float, ...], y_pred: tuple[float, ...]) -> float", + "precision": "function alphalab.ml.evaluation.precision(y_true: tuple[int, ...], y_pred: tuple[int, ...]) -> float", + "predict_linear": "function alphalab.ml.linear_regression.predict_linear(model: alphalab.ml.linear_regression.LinearRegressionModel, x: tuple[tuple[float, ...], ...]) -> tuple[float, ...]", + "predict_logistic": "function alphalab.ml.logistic_regression.predict_logistic(model: alphalab.ml.logistic_regression.LogisticRegressionModel, x: tuple[tuple[float, ...], ...], threshold: float = 0.5) -> tuple[int, ...]", + "predict_proba_logistic": "function alphalab.ml.logistic_regression.predict_proba_logistic(model: alphalab.ml.logistic_regression.LogisticRegressionModel, x: tuple[tuple[float, ...], ...]) -> tuple[float, ...]", + "r_squared": "function alphalab.ml.evaluation.r_squared(y_true: tuple[float, ...], y_pred: tuple[float, ...]) -> float", + "recall": "function alphalab.ml.evaluation.recall(y_true: tuple[int, ...], y_pred: tuple[int, ...]) -> float", + "root_mean_squared_error": "function alphalab.ml.evaluation.root_mean_squared_error(y_true: tuple[float, ...], y_pred: tuple[float, ...]) -> float", + "train_linear_regression": "function alphalab.ml.linear_regression.train_linear_regression(feature_names: tuple[str, ...], x: tuple[tuple[float, ...], ...], y: tuple[float, ...], l2_penalty: float = 0.0, *, maximum_condition: float = 1000000.0) -> alphalab.ml.linear_regression.LinearRegressionModel", + "train_logistic_regression": "function alphalab.ml.logistic_regression.train_logistic_regression(feature_names: tuple[str, ...], x: tuple[tuple[float, ...], ...], y: tuple[int, ...], learning_rate: float = 0.1, iterations: int = 1000) -> alphalab.ml.logistic_regression.LogisticRegressionModel", + "transpose": "function alphalab.ml.linalg.transpose(matrix: tuple[tuple[float, ...], ...]) -> tuple[tuple[float, ...], ...]", + "walk_forward_split": "function alphalab.ml.cross_validation.walk_forward_split(n_samples: int, n_splits: int, min_train_size: int, expanding: bool = True) -> tuple[tuple[tuple[int, ...], tuple[int, ...]], ...]" + }, + "alphalab.model_registry": { + "ARTIFACT_URI_SCHEME": "value builtins.str", + "ArtifactRef": "class alphalab.model_registry.registry.ArtifactRef(uri: str, media_type: str = '', checksum: str = '', size_bytes: int | None = None) -> None", + "ArtifactStore": "class alphalab.model_registry.artifact_store.ArtifactStore(*args, **kwargs)", + "DEFAULT_MEDIA_TYPE": "value builtins.str", + "DeploymentMetadata": "class alphalab.model_registry.registry.DeploymentMetadata(environment: str, deployed_at: float, deployed_by: str, notes: str = '') -> None", + "EvidenceIndex": "class alphalab.model_registry.evidence.EvidenceIndex(*args, **kwargs)", + "EvidenceItem": "class alphalab.model_registry.evidence.EvidenceItem(ref: 'EvidenceRef', value: 'Any') -> None", + "EvidenceRef": "class alphalab.model_registry.evidence.EvidenceRef(kind: 'str', identity: 'str', digest: 'str') -> None", + "EvidenceStore": "class alphalab.model_registry.evidence.EvidenceStore(artifacts: 'ArtifactStore', index: 'EvidenceIndex') -> 'None'", + "FileArtifactStore": "class alphalab.model_registry.artifact_store.FileArtifactStore(root: 'Path | str') -> 'None'", + "FileEvidenceIndex": "class alphalab.model_registry.evidence.FileEvidenceIndex(root: 'Path | str') -> 'None'", + "LEGAL_TRANSITIONS": "value builtins.dict", + "MemoryArtifactStore": "class alphalab.model_registry.artifact_store.MemoryArtifactStore() -> 'None'", + "MemoryEvidenceIndex": "class alphalab.model_registry.evidence.MemoryEvidenceIndex(entries: 'dict[tuple[str, str], str]' = ) -> None", + "ModelRegistry": "class alphalab.model_registry.registry.ModelRegistry(versions: alphalab.common.persistent_map.PersistentMap[str, alphalab.common.persistent_map.PersistentMap[int, alphalab.model_registry.registry.ModelVersion]] = , promotions: alphalab.common.append_log.AppendOnlyLog[alphalab.model_registry.registry.PromotionRecord] = , production: alphalab.common.persistent_map.PersistentMap[str, int] = , production_line: alphalab.common.persistent_map.PersistentMap[str, alphalab.common.append_log.AppendOnlyLog[int]] = ) -> None", + "ModelRegistryError": "class alphalab.model_registry.exceptions.ModelRegistryError", + "ModelRegistryInputError": "class alphalab.model_registry.exceptions.ModelRegistryInputError", + "ModelStage": "enum alphalab.model_registry.registry.ModelStage[NONE, STAGING, PRODUCTION, ARCHIVED]", + "ModelVersion": "class alphalab.model_registry.registry.ModelVersion(name: str, version: int, model: object, stage: alphalab.model_registry.registry.ModelStage, metrics: collections.abc.Mapping[str, float] = , parameters: collections.abc.Mapping[str, ParamValue] = , tags: collections.abc.Mapping[str, str] = , run_id: str | None = None, created_at: float = 0.0, deployment: alphalab.model_registry.registry.DeploymentMetadata | None = None, artifact: alphalab.model_registry.registry.ArtifactRef | None = None) -> None", + "ParamValue": "alias ParamValue", + "PromotionRecord": "class alphalab.model_registry.registry.PromotionRecord(name: str, version: int, from_stage: alphalab.model_registry.registry.ModelStage, to_stage: alphalab.model_registry.registry.ModelStage, timestamp: float) -> None", + "SelfIdentified": "class alphalab.model_registry.evidence.SelfIdentified(*args, **kwargs)", + "artifact_uri": "function alphalab.model_registry.artifact_store.artifact_uri(digest: 'str') -> 'str'", + "compute_digest": "function alphalab.model_registry.artifact_store.compute_digest(payload: 'bytes') -> 'str'", + "deployed_versions": "function alphalab.model_registry.deployment.deployed_versions(registry: alphalab.model_registry.registry.ModelRegistry, name: str) -> tuple[alphalab.model_registry.registry.ModelVersion, ...]", + "deployment_metadata": "function alphalab.model_registry.deployment.deployment_metadata(registry: alphalab.model_registry.registry.ModelRegistry, name: str, version: int) -> alphalab.model_registry.registry.DeploymentMetadata | None", + "digest_of": "function alphalab.model_registry.artifact_store.digest_of(ref: 'ArtifactRef') -> 'str'", + "file_evidence_store": "function alphalab.model_registry.evidence.file_evidence_store(root: 'Path | str') -> 'EvidenceStore'", + "get_model": "function alphalab.model_registry.registry.get_model(registry: alphalab.model_registry.registry.ModelRegistry, name: str, version: int, expected_type: type[ModelT]) -> ModelT", + "get_version": "function alphalab.model_registry.registry.get_version(registry: alphalab.model_registry.registry.ModelRegistry, name: str, version: int) -> alphalab.model_registry.registry.ModelVersion", + "illegal_stage_move": "function alphalab.model_registry.stages.illegal_stage_move(current: alphalab.model_registry.registry.ModelStage, target: alphalab.model_registry.registry.ModelStage) -> str | None", + "latest_version": "function alphalab.model_registry.registry.latest_version(registry: alphalab.model_registry.registry.ModelRegistry, name: str) -> alphalab.model_registry.registry.ModelVersion", + "list_versions": "function alphalab.model_registry.registry.list_versions(registry: alphalab.model_registry.registry.ModelRegistry, name: str) -> tuple[alphalab.model_registry.registry.ModelVersion, ...]", + "memory_evidence_store": "function alphalab.model_registry.evidence.memory_evidence_store() -> 'EvidenceStore'", + "model_names": "function alphalab.model_registry.registry.model_names(registry: alphalab.model_registry.registry.ModelRegistry) -> tuple[str, ...]", + "previous_production_version": "function alphalab.model_registry.stages.previous_production_version(registry: alphalab.model_registry.registry.ModelRegistry, name: str) -> int | None", + "production_version": "function alphalab.model_registry.promotion.production_version(registry: alphalab.model_registry.registry.ModelRegistry, name: str) -> alphalab.model_registry.registry.ModelVersion | None", + "promote": "function alphalab.model_registry.promotion.promote(registry: alphalab.model_registry.registry.ModelRegistry, name: str, version: int, stage: alphalab.model_registry.registry.ModelStage, timestamp: float) -> alphalab.model_registry.registry.ModelRegistry", + "promotion_history": "function alphalab.model_registry.rollback.promotion_history(registry: alphalab.model_registry.registry.ModelRegistry, name: str | None = None) -> tuple[alphalab.model_registry.registry.PromotionRecord, ...]", + "register_model": "function alphalab.model_registry.registry.register_model(registry: alphalab.model_registry.registry.ModelRegistry, name: str, model: object, timestamp: float, metrics: collections.abc.Mapping[str, float] | None = None, parameters: collections.abc.Mapping[str, ParamValue] | None = None, tags: collections.abc.Mapping[str, str] | None = None, run_id: str | None = None, artifact: alphalab.model_registry.registry.ArtifactRef | None = None) -> tuple[alphalab.model_registry.registry.ModelRegistry, alphalab.model_registry.registry.ModelVersion]", + "rollback": "function alphalab.model_registry.rollback.rollback(registry: alphalab.model_registry.registry.ModelRegistry, name: str, timestamp: float) -> alphalab.model_registry.registry.ModelRegistry", + "set_deployment_metadata": "function alphalab.model_registry.deployment.set_deployment_metadata(registry: alphalab.model_registry.registry.ModelRegistry, name: str, version: int, metadata: alphalab.model_registry.registry.DeploymentMetadata) -> alphalab.model_registry.registry.ModelRegistry", + "staging_version": "function alphalab.model_registry.promotion.staging_version(registry: alphalab.model_registry.registry.ModelRegistry, name: str) -> alphalab.model_registry.registry.ModelVersion | None", + "validate_transition": "function alphalab.model_registry.stages.validate_transition(registry: alphalab.model_registry.registry.ModelRegistry, name: str, version: int, current: alphalab.model_registry.registry.ModelStage, target: alphalab.model_registry.registry.ModelStage) -> None", + "verify_artifact": "function alphalab.model_registry.artifact_store.verify_artifact(ref: 'ArtifactRef', payload: 'bytes') -> 'bytes'", + "versions_in_stage": "function alphalab.model_registry.promotion.versions_in_stage(registry: alphalab.model_registry.registry.ModelRegistry, name: str, stage: alphalab.model_registry.registry.ModelStage) -> tuple[alphalab.model_registry.registry.ModelVersion, ...]" + }, + "alphalab.oms": { + "DuplicateOrderError": "class alphalab.oms.exceptions.DuplicateOrderError", + "InvalidTransitionError": "class alphalab.oms.exceptions.InvalidTransitionError", + "OMSEngine": "class alphalab.oms.engine.OMSEngine()", + "OMSError": "class alphalab.oms.exceptions.OMSError", + "OMSEvent": "class alphalab.oms.events.OMSEvent(event_id: str, timestamp: float, order_id: alphalab.oms.ids.OrderId) -> None", + "OMSEventRecord": "class alphalab.oms.snapshot.OMSEventRecord(event_type: 'str', event: 'OMSEvent') -> None", + "OMSSnapshot": "class alphalab.oms.snapshot.OMSSnapshot(orders: 'tuple[Order, ...]', active_orders: 'tuple[OrderId, ...]', completed_orders: 'tuple[OrderId, ...]', history: 'tuple[OMSEventRecord, ...]', events: 'tuple[OMSEventRecord, ...]', schema_version: 'int' = 2) -> None", + "OMSState": "class alphalab.oms.state.OMSState(orders: alphalab.oms.book.OrderBook = , active_orders: alphalab.common.persistent_map.PersistentSet[alphalab.oms.ids.OrderId] = , completed_orders: alphalab.common.persistent_map.PersistentSet[alphalab.oms.ids.OrderId] = , history: alphalab.common.append_log.AppendOnlyLog[alphalab.oms.events.OMSEvent] = , events: alphalab.common.append_log.AppendOnlyLog[alphalab.oms.events.OMSEvent] = , working_by_asset: alphalab.common.persistent_map.PersistentMap[str, alphalab.common.persistent_map.PersistentSet[alphalab.oms.ids.OrderId]] = ) -> None", + "Order": "class alphalab.oms.order.Order(order_id: 'OrderId', strategy_id: 'str', asset_id: 'str', side: 'Side', order_type: 'OrderType', status: 'OrderStatus', quantity: 'Decimal', filled_quantity: 'Decimal', remaining_quantity: 'Decimal', limit_price: 'Decimal | None', stop_price: 'Decimal | None', average_fill_price: 'Decimal', created_at: 'float', updated_at: 'float', metadata: 'Mapping[str, str]' = , time_in_force: 'TimeInForce' = , expire_at: 'float | None' = None, triggered_at: 'float | None' = None) -> None", + "OrderAccepted": "class alphalab.oms.events.OrderAccepted(event_id: str, timestamp: float, order_id: alphalab.oms.ids.OrderId) -> None", + "OrderBook": "class alphalab.oms.book.OrderBook(_orders: alphalab.common.persistent_map.PersistentMap[alphalab.oms.ids.OrderId, alphalab.oms.order.Order] = , _by_asset: alphalab.common.persistent_map.PersistentMap[str, alphalab.common.persistent_map.PersistentSet[alphalab.oms.ids.OrderId]] = , _by_strategy: alphalab.common.persistent_map.PersistentMap[str, alphalab.common.persistent_map.PersistentSet[alphalab.oms.ids.OrderId]] = ) -> None", + "OrderCancelled": "class alphalab.oms.events.OrderCancelled(event_id: str, timestamp: float, order_id: alphalab.oms.ids.OrderId) -> None", + "OrderExpired": "class alphalab.oms.events.OrderExpired(event_id: str, timestamp: float, order_id: alphalab.oms.ids.OrderId) -> None", + "OrderFilled": "class alphalab.oms.events.OrderFilled(event_id: str, timestamp: float, order_id: alphalab.oms.ids.OrderId, fill_quantity: decimal.Decimal, fill_price: decimal.Decimal) -> None", + "OrderId": "class alphalab.oms.ids.OrderId(value: 'uuid.UUID') -> None", + "OrderPartiallyFilled": "class alphalab.oms.events.OrderPartiallyFilled(event_id: str, timestamp: float, order_id: alphalab.oms.ids.OrderId, fill_quantity: decimal.Decimal, fill_price: decimal.Decimal) -> None", + "OrderRejected": "class alphalab.oms.events.OrderRejected(event_id: str, timestamp: float, order_id: alphalab.oms.ids.OrderId, reason: str) -> None", + "OrderReplaced": "class alphalab.oms.events.OrderReplaced(event_id: str, timestamp: float, order_id: alphalab.oms.ids.OrderId, new_quantity: decimal.Decimal, new_limit_price: decimal.Decimal | None) -> None", + "OrderStatus": "enum alphalab.core.enums.OrderStatus[NEW, PENDING, ACCEPTED, PARTIALLY_FILLED, FILLED, CANCEL_PENDING, CANCELLED, REJECTED, EXPIRED]", + "OrderSubmitted": "class alphalab.oms.events.OrderSubmitted(event_id: str, timestamp: float, order_id: alphalab.oms.ids.OrderId, order: alphalab.oms.order.Order) -> None", + "OrderType": "enum alphalab.common.order_terms.OrderType[MARKET, LIMIT, STOP, STOP_LIMIT]", + "OrderValidationError": "class alphalab.oms.exceptions.OrderValidationError", + "Side": "enum alphalab.core.enums.Side[BUY, SELL]", + "SnapshotDecodeError": "class alphalab.oms.snapshot.SnapshotDecodeError", + "UnknownOrderError": "class alphalab.oms.exceptions.UnknownOrderError", + "active_orders": "function alphalab.oms.views.active_orders(state: alphalab.oms.state.OMSState) -> collections.abc.Sequence[alphalab.oms.order.Order]", + "capture": "function alphalab.oms.snapshot.capture(state: 'OMSState') -> 'OMSSnapshot'", + "completed_orders": "function alphalab.oms.views.completed_orders(state: alphalab.oms.state.OMSState) -> collections.abc.Sequence[alphalab.oms.order.Order]", + "filled_volume": "function alphalab.oms.views.filled_volume(state: alphalab.oms.state.OMSState, asset_id: str) -> decimal.Decimal", + "from_primitives": "function alphalab.oms.snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'OMSSnapshot'", + "order": "function alphalab.oms.views.order(state: alphalab.oms.state.OMSState, order_id: alphalab.oms.ids.OrderId) -> alphalab.oms.order.Order", + "orders": "function alphalab.oms.views.orders(state: alphalab.oms.state.OMSState) -> collections.abc.Sequence[alphalab.oms.order.Order]", + "orders_for_asset": "function alphalab.oms.views.orders_for_asset(state: alphalab.oms.state.OMSState, asset_id: str) -> collections.abc.Sequence[alphalab.oms.order.Order]", + "orders_for_strategy": "function alphalab.oms.views.orders_for_strategy(state: alphalab.oms.state.OMSState, strategy_id: str) -> collections.abc.Sequence[alphalab.oms.order.Order]", + "restore": "function alphalab.oms.snapshot.restore(snapshot: 'OMSSnapshot') -> 'OMSState'", + "validate_order": "function alphalab.oms.validation.validate_order(order: alphalab.oms.order.Order, current_timestamp: float) -> None" + }, + "alphalab.oms.snapshot": { + "LEGACY_UNVERSIONED_V0_KEYS": "value builtins.frozenset", + "OMSEventRecord": "class alphalab.oms.snapshot.OMSEventRecord(event_type: 'str', event: 'OMSEvent') -> None", + "OMSSnapshot": "class alphalab.oms.snapshot.OMSSnapshot(orders: 'tuple[Order, ...]', active_orders: 'tuple[OrderId, ...]', completed_orders: 'tuple[OrderId, ...]', history: 'tuple[OMSEventRecord, ...]', events: 'tuple[OMSEventRecord, ...]', schema_version: 'int' = 2) -> None", + "OMS_SCHEMA_HISTORY": "value alphalab.persistence.upgrade.SchemaHistory", + "OMS_SNAPSHOT_SCHEMA": "value builtins.int", + "SnapshotDecodeError": "class alphalab.oms.snapshot.SnapshotDecodeError", + "capture": "function alphalab.oms.snapshot.capture(state: 'OMSState') -> 'OMSSnapshot'", + "from_primitives": "function alphalab.oms.snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'OMSSnapshot'", + "order_v1_to_v2": "function alphalab.oms.snapshot.order_v1_to_v2(order: 'Any') -> 'Any'", + "restore": "function alphalab.oms.snapshot.restore(snapshot: 'OMSSnapshot') -> 'OMSState'" + }, + "alphalab.options": { + "BLACK_SCHOLES_MERTON": "value alphalab.options.model.ModelAssumptions", + "BinomialLattice": "class alphalab.options.binomial.BinomialLattice(steps: 'int', dividends: 'tuple[CashDividend, ...]') -> None", + "Carry": "class alphalab.options.carry.Carry(kind: 'CarryKind', yield_rate: 'float') -> None", + "CarryKind": "enum alphalab.options.carry.CarryKind[DIVIDEND_YIELD, FOREIGN_RATE, FUTURES]", + "CashDividend": "class alphalab.options.binomial.CashDividend(ex_timestamp: 'float', amount: 'Decimal') -> None", + "ExerciseStyle": "enum alphalab.options.enums.ExerciseStyle[AMERICAN, EUROPEAN]", + "ExpirationOutcome": "enum alphalab.options.expiration.ExpirationOutcome[EXPIRED_WORTHLESS, EXERCISED, ASSIGNED, ABANDONED]", + "ExpirationPolicy": "class alphalab.options.expiration.ExpirationPolicy(settlement: 'SettlementStyle', exercise_in_the_money: 'bool') -> None", + "ExpirationResult": "class alphalab.options.expiration.ExpirationResult(contract_symbol: 'str', outcome: 'ExpirationOutcome', moneyness: 'Moneyness', contracts: 'Decimal', intrinsic_per_unit: 'Decimal', cash_flow: 'Decimal', underlying_units: 'Decimal') -> None", + "ExpiryInterpolation": "enum alphalab.options.volatility_surface.ExpiryInterpolation[TOTAL_VARIANCE_LINEAR]", + "FUTURES_CARRY": "value alphalab.options.carry.Carry", + "Greeks": "class alphalab.options.greeks.Greeks(delta: float, gamma: float, theta: float, vega: float, rho: float) -> None", + "ImpliedVolatility": "class alphalab.options.implied.ImpliedVolatility(value: 'float', market_price: 'float', repriced: 'float', vega: 'float', iterations: 'int', assumptions: 'ModelAssumptions', carry: 'Carry', lattice: 'BinomialLattice | None' = None) -> None", + "ImpliedVolatilityError": "class alphalab.options.implied.ImpliedVolatilityError", + "MAX_STEPS": "value builtins.int", + "ModelAssumptions": "class alphalab.options.model.ModelAssumptions(model: 'PricingModel', year_basis_days: 'float', prices_early_exercise: 'bool', models_dividends: 'bool', models_volatility_smile: 'bool', note: 'str', models_discrete_dividends: 'bool' = False, steps: 'int | None' = None) -> None", + "Moneyness": "enum alphalab.options.expiration.Moneyness[IN_THE_MONEY, AT_THE_MONEY, OUT_OF_THE_MONEY]", + "OptionChain": "class alphalab.options.chain.OptionChain(underlying_asset_id: str, timestamp: float, contracts: tuple[alphalab.options.contract.OptionContract, ...]) -> None", + "OptionContract": "class alphalab.options.contract.OptionContract(underlying_asset_id: str, strike: decimal.Decimal, expiry: float, option_type: alphalab.options.enums.OptionType, style: alphalab.options.enums.ExerciseStyle, multiplier: int) -> None", + "OptionInputError": "class alphalab.options.exceptions.OptionInputError", + "OptionLeg": "class alphalab.options.strategy.OptionLeg(contract: alphalab.options.contract.OptionContract, side: alphalab.core.enums.Side, quantity: int) -> None", + "OptionPricingError": "class alphalab.options.exceptions.OptionPricingError", + "OptionStrategy": "class alphalab.options.strategy.OptionStrategy(legs: tuple[alphalab.options.strategy.OptionLeg, ...]) -> None", + "OptionType": "enum alphalab.options.enums.OptionType[CALL, PUT]", + "OptionsError": "class alphalab.options.exceptions.OptionsError", + "PricingModel": "enum alphalab.options.model.PricingModel[BLACK_SCHOLES, BINOMIAL_CRR]", + "SettlementStyle": "enum alphalab.options.expiration.SettlementStyle[PHYSICAL, CASH]", + "SurfaceRefusal": "class alphalab.options.volatility_surface.SurfaceRefusal(contract_symbol: str, strike: float, expiry: float, reason: str) -> None", + "VolPoint": "class alphalab.options.volatility_surface.VolPoint(strike: float, expiry: float, implied_vol: float) -> None", + "VolSlice": "class alphalab.options.volatility_surface.VolSlice(underlying_asset_id: str, expiry: float, points: tuple[alphalab.options.volatility_surface.VolPoint, ...]) -> None", + "VolatilitySurface": "class alphalab.options.volatility_surface.VolatilitySurface(underlying_asset_id: str, timestamp: float, points: tuple[alphalab.options.volatility_surface.VolPoint, ...]) -> None", + "binomial_greeks": "function alphalab.options.binomial.binomial_greeks(contract: 'OptionContract', spot: 'Decimal', volatility: 'float', risk_free_rate: 'float', valuation_timestamp: 'float', *, carry: 'Carry', lattice: 'BinomialLattice') -> 'Greeks'", + "binomial_price": "function alphalab.options.binomial.binomial_price(contract: 'OptionContract', spot: 'Decimal', volatility: 'float', risk_free_rate: 'float', valuation_timestamp: 'float', *, carry: 'Carry', lattice: 'BinomialLattice') -> 'Decimal'", + "binomial_value": "function alphalab.options.binomial.binomial_value(contract: 'OptionContract', spot: 'float', volatility: 'float', risk_free_rate: 'float', valuation_timestamp: 'float', *, carry: 'Carry', lattice: 'BinomialLattice') -> 'float'", + "black_scholes_greeks": "function alphalab.options.pricing.black_scholes_greeks(contract: alphalab.options.contract.OptionContract, spot: decimal.Decimal, volatility: float, risk_free_rate: float, valuation_timestamp: float, *, carry: alphalab.options.carry.Carry) -> alphalab.options.greeks.Greeks", + "black_scholes_price": "function alphalab.options.pricing.black_scholes_price(contract: alphalab.options.contract.OptionContract, spot: decimal.Decimal, volatility: float, risk_free_rate: float, valuation_timestamp: float, *, carry: alphalab.options.carry.Carry) -> decimal.Decimal", + "black_scholes_value": "function alphalab.options.pricing.black_scholes_value(contract: alphalab.options.contract.OptionContract, spot: float, volatility: float, risk_free_rate: float, years: float, *, carry: alphalab.options.carry.Carry) -> float", + "by_expiry": "function alphalab.options.chain.by_expiry(chain: alphalab.options.chain.OptionChain, expiry: float) -> tuple[alphalab.options.contract.OptionContract, ...]", + "calls": "function alphalab.options.chain.calls(chain: alphalab.options.chain.OptionChain) -> tuple[alphalab.options.contract.OptionContract, ...]", + "compute_payoff_at_expiry": "function alphalab.options.strategy.compute_payoff_at_expiry(strategy: alphalab.options.strategy.OptionStrategy, spot_price: decimal.Decimal) -> decimal.Decimal", + "compute_pnl": "function alphalab.options.strategy.compute_pnl(strategy: alphalab.options.strategy.OptionStrategy, entry_prices: collections.abc.Mapping[str, decimal.Decimal], spot_price: decimal.Decimal) -> decimal.Decimal", + "dividend_yield": "function alphalab.options.carry.dividend_yield(q: 'float') -> 'Carry'", + "expiries": "function alphalab.options.chain.expiries(chain: alphalab.options.chain.OptionChain) -> tuple[float, ...]", + "foreign_rate": "function alphalab.options.carry.foreign_rate(rate: 'float') -> 'Carry'", + "implied_vol_across_expiries": "function alphalab.options.volatility_surface.implied_vol_across_expiries(surface: alphalab.options.volatility_surface.VolatilitySurface, strike: float, expiry: float, *, method: alphalab.options.volatility_surface.ExpiryInterpolation) -> float", + "implied_vol_at": "function alphalab.options.volatility_surface.implied_vol_at(surface: alphalab.options.volatility_surface.VolatilitySurface, strike: float, expiry: float) -> float | None", + "implied_volatility": "function alphalab.options.implied.implied_volatility(contract: 'OptionContract', market_price: 'Decimal', spot: 'Decimal', risk_free_rate: 'float', valuation_timestamp: 'float', *, carry: 'Carry', lattice: 'BinomialLattice | None' = None) -> 'ImpliedVolatility'", + "intrinsic_value": "function alphalab.options.expiration.intrinsic_value(contract: 'OptionContract', settlement_price: 'Decimal') -> 'Decimal'", + "moneyness": "function alphalab.options.expiration.moneyness(contract: 'OptionContract', settlement_price: 'Decimal') -> 'Moneyness'", + "net_greeks": "function alphalab.options.strategy.net_greeks(strategy: alphalab.options.strategy.OptionStrategy, greeks_by_symbol: collections.abc.Mapping[str, alphalab.options.greeks.Greeks]) -> alphalab.options.greeks.Greeks", + "net_premium": "function alphalab.options.strategy.net_premium(strategy: alphalab.options.strategy.OptionStrategy, entry_prices: collections.abc.Mapping[str, decimal.Decimal]) -> decimal.Decimal", + "occ_symbol": "function alphalab.options.contract.occ_symbol(contract: alphalab.options.contract.OptionContract) -> str", + "open_option_position": "function alphalab.options.contract.open_option_position(contract: alphalab.options.contract.OptionContract, quantity: decimal.Decimal, price: decimal.Decimal, timestamp: float, currency: str, *, minor_units: int | None = None) -> alphalab.portfolio.position.Position", + "puts": "function alphalab.options.chain.puts(chain: alphalab.options.chain.OptionChain) -> tuple[alphalab.options.contract.OptionContract, ...]", + "resolve_expiration": "function alphalab.options.expiration.resolve_expiration(contract: 'OptionContract', contracts: 'Decimal', settlement_price: 'Decimal', policy: 'ExpirationPolicy') -> 'ExpirationResult'", + "resolve_strategy_expiration": "function alphalab.options.expiration.resolve_strategy_expiration(strategy: 'OptionStrategy', settlement_price: 'Decimal', policy: 'ExpirationPolicy') -> 'tuple[ExpirationResult, ...]'", + "signed_quantity": "function alphalab.options.strategy.signed_quantity(leg: alphalab.options.strategy.OptionLeg) -> decimal.Decimal", + "strikes_for_expiry": "function alphalab.options.chain.strikes_for_expiry(chain: alphalab.options.chain.OptionChain, expiry: float) -> tuple[float, ...]", + "surface_expiries": "function alphalab.options.volatility_surface.surface_expiries(surface: alphalab.options.volatility_surface.VolatilitySurface) -> tuple[float, ...]", + "surface_from_chain": "function alphalab.options.volatility_surface.surface_from_chain(chain: alphalab.options.chain.OptionChain, market_prices: collections.abc.Mapping[str, decimal.Decimal], spot: decimal.Decimal, risk_free_rate: float, valuation_timestamp: float, *, carry: alphalab.options.carry.Carry, lattice: alphalab.options.binomial.BinomialLattice | None = None) -> tuple[alphalab.options.volatility_surface.VolatilitySurface, tuple[alphalab.options.volatility_surface.SurfaceRefusal, ...]]", + "surface_slice": "function alphalab.options.volatility_surface.surface_slice(surface: alphalab.options.volatility_surface.VolatilitySurface, expiry: float) -> alphalab.options.volatility_surface.VolSlice", + "term_structure": "function alphalab.options.volatility_surface.term_structure(surface: alphalab.options.volatility_surface.VolatilitySurface, strike: float) -> tuple[tuple[float, float], ...]", + "time_to_expiry_years": "function alphalab.options.pricing.time_to_expiry_years(contract: alphalab.options.contract.OptionContract, valuation_timestamp: float) -> float" + }, + "alphalab.persistence": { + "FileRunStateStore": "class alphalab.persistence.run_store.FileRunStateStore(root: 'Path | str') -> 'None'", + "MemoryRunStateStore": "class alphalab.persistence.run_store.MemoryRunStateStore() -> 'None'", + "PersistenceError": "class alphalab.persistence.exceptions.PersistenceError", + "PersistenceValidationError": "class alphalab.persistence.exceptions.PersistenceValidationError", + "RUN_STATE_ENVELOPE_SCHEMA": "value builtins.int", + "RunStateRef": "class alphalab.persistence.run_state.RunStateRef(run_id: 'str', sequence: 'int') -> None", + "RunStateStore": "class alphalab.persistence.run_store.RunStateStore(*args, **kwargs)", + "SerializationError": "class alphalab.persistence.exceptions.SerializationError", + "StateDecodeError": "class alphalab.persistence.exceptions.StateDecodeError", + "StorageError": "class alphalab.persistence.exceptions.StorageError", + "as_decimal": "function alphalab.persistence.decode.as_decimal(value: 'Any', field: 'str') -> 'Decimal'", + "as_float": "function alphalab.persistence.decode.as_float(value: 'Any', field: 'str') -> 'float'", + "as_int": "function alphalab.persistence.decode.as_int(value: 'Any', field: 'str') -> 'int'", + "as_mapping": "function alphalab.persistence.decode.as_mapping(value: 'Any', field: 'str') -> 'Mapping[str, Any]'", + "as_named_enum": "function alphalab.persistence.decode.as_named_enum(enum_cls: 'type[EnumT]', value: 'Any', field: 'str') -> 'EnumT'", + "as_sequence": "function alphalab.persistence.decode.as_sequence(value: 'Any', field: 'str') -> 'Sequence[Any]'", + "as_str": "function alphalab.persistence.decode.as_str(value: 'Any', field: 'str') -> 'str'", + "as_value_enum": "function alphalab.persistence.decode.as_value_enum(enum_cls: 'type[EnumT]', value: 'Any', field: 'str') -> 'EnumT'", + "deserialize": "function alphalab.persistence.serializer.deserialize(payload: str) -> Any", + "ensure_directory": "function alphalab.persistence.durable.ensure_directory(directory: 'str | os.PathLike[str]') -> 'None'", + "fsync_directory": "function alphalab.persistence.durable.fsync_directory(directory: 'str | os.PathLike[str]') -> 'None'", + "require": "function alphalab.persistence.decode.require(payload: 'Mapping[str, Any]', key: 'str') -> 'Any'", + "require_schema_version": "function alphalab.persistence.decode.require_schema_version(payload: 'Mapping[str, Any]', supported: 'int', subsystem: 'str') -> 'int'", + "serialize": "function alphalab.persistence.serializer.serialize(obj: Any) -> str" + }, + "alphalab.portfolio": { + "Account": "class alphalab.portfolio.account.Account(account_id: str, base_currency: str, name: str, created_at: float, status: str = 'ACTIVE', metadata: collections.abc.Mapping[str, Any] = , currency_units: alphalab.common.currency_units.CurrencyUnits = CurrencyUnits({})) -> None", + "Assignment": "class alphalab.portfolio.corporate_actions.Assignment(asset_id: 'str', contracts: 'Decimal', underlying_value: 'Decimal | None' = None, reference: 'str' = '') -> None", + "BOOK_VALUATION_SCHEME": "value builtins.str", + "BookValuation": "class alphalab.portfolio.multi_strategy.BookValuation(portfolio_id: 'str', book_id: 'str', reporting_currency: 'str', as_of: 'float', lines: 'tuple[HoldingValuation, ...]', strategies: 'tuple[StrategyValuation, ...]', instruments: 'tuple[InstrumentValuation, ...]', exposure_by_currency: 'CurrencyAmounts', reporting_exposure_by_currency: 'Mapping[str, Decimal]', long_exposure: 'Decimal', short_exposure: 'Decimal', net_exposure: 'Decimal', gross_exposure: 'Decimal', cash: 'Decimal', unassigned_cash: 'Decimal', nav: 'Decimal', conversions: 'tuple[FxConversion, ...]') -> None", + "CashDeposited": "class alphalab.portfolio.events.CashDeposited(timestamp: float, account_id: str, amount: decimal.Decimal, currency: str) -> None", + "CashFlow": "class alphalab.portfolio.corporate_actions.CashFlow(kind: 'CashFlowKind', amount: 'Decimal', currency: 'str', asset_id: 'str' = '', reference: 'str' = '') -> None", + "CashFlowBooked": "class alphalab.portfolio.events.CashFlowBooked(timestamp: float, account_id: str, kind: str, asset_id: str, amount: decimal.Decimal, currency: str, reference: str) -> None", + "CashFlowKind": "enum alphalab.portfolio.corporate_actions.CashFlowKind[DIVIDEND, INTEREST, FEE, FUNDING]", + "CashLedger": "class alphalab.portfolio.cash.CashLedger(balances: collections.abc.Mapping[str, decimal.Decimal] = , reserved: collections.abc.Mapping[str, decimal.Decimal] = ) -> None", + "CashWithdrawn": "class alphalab.portfolio.events.CashWithdrawn(timestamp: float, account_id: str, amount: decimal.Decimal, currency: str) -> None", + "ContractExposure": "class alphalab.portfolio.contracts.ContractExposure(by_asset: 'Mapping[str, ContractNotional]', net: 'CurrencyAmounts', gross: 'CurrencyAmounts', underlying_units: 'Mapping[str, Decimal]') -> None", + "ContractHolding": "class alphalab.portfolio.contracts.ContractHolding(position: 'Position', convention: 'MarketConvention') -> None", + "ContractSettlement": "class alphalab.portfolio.corporate_actions.ContractSettlement(asset_id: 'str', price: 'Decimal', reference: 'str' = '') -> None", + "CurrencyAttribution": "class alphalab.portfolio.fx_research.CurrencyAttribution(currency: 'str', opening_local: 'Decimal', closing_local: 'Decimal', opening_reporting: 'Decimal', closing_reporting: 'Decimal', opening_rate: 'FxRate', closing_rate: 'FxRate', local_return: 'Decimal', currency_return: 'Decimal', total: 'Decimal') -> None", + "CurrencyAttributionReport": "class alphalab.portfolio.fx_research.CurrencyAttributionReport(reporting_currency: 'str', opening_timestamp: 'float', closing_timestamp: 'float', by_currency: 'tuple[CurrencyAttribution, ...]', local_return: 'Decimal', currency_return: 'Decimal', total: 'Decimal', rounding: 'Decimal') -> None", + "Exercise": "class alphalab.portfolio.corporate_actions.Exercise(asset_id: 'str', contracts: 'Decimal', underlying_value: 'Decimal | None' = None, reference: 'str' = '') -> None", + "ExposureEngine": "class alphalab.portfolio.exposure.ExposureEngine()", + "ForwardTerms": "class alphalab.portfolio.fx_research.ForwardTerms(value_date: 'date', spot_date: 'date', base_rate: 'float', quote_rate: 'float', basis: 'DayCount') -> None", + "FutureDatedRateError": "class alphalab.portfolio.fx.FutureDatedRateError", + "FxConversion": "class alphalab.portfolio.fx.FxConversion(amount: 'Decimal', converted: 'Decimal', rate: 'FxRate') -> None", + "FxRate": "class alphalab.portfolio.fx.FxRate(base: 'str', quote: 'str', rate: 'Decimal', as_of: 'float', source: 'str', derived: 'bool' = False) -> None", + "FxRates": "class alphalab.portfolio.fx.FxRates(rates: 'Mapping[tuple[str, str], FxRate]' = , max_age_seconds: 'float | None' = None, currency_units: 'CurrencyUnits' = CurrencyUnits({})) -> None", + "HoldingValuation": "class alphalab.portfolio.multi_strategy.HoldingValuation(strategy_id: 'str', asset_id: 'str', quantity: 'Decimal', market_price: 'Decimal', currency: 'str', market_value: 'Decimal', reporting_value: 'Decimal', conversion: 'FxConversion | None', unrealized_pnl: 'Decimal', reporting_unrealized_pnl: 'Decimal') -> None", + "InstrumentHolding": "class alphalab.portfolio.multi_strategy.InstrumentHolding(asset_id: 'str', currency: 'str', market_price: 'Decimal', net_quantity: 'Decimal', contributions: 'tuple[StrategyContribution, ...]', long_quantity: 'Decimal', short_quantity: 'Decimal', crossed_quantity: 'Decimal') -> None", + "InstrumentValuation": "class alphalab.portfolio.multi_strategy.InstrumentValuation(holding: 'InstrumentHolding', market_value: 'Decimal', reporting_value: 'Decimal', gross_reporting_value: 'Decimal') -> None", + "InsufficientFundsError": "class alphalab.portfolio.exceptions.InsufficientFundsError", + "InvalidTransactionError": "class alphalab.portfolio.exceptions.InvalidTransactionError", + "MULTI_STRATEGY_BOOK_SCHEME": "value builtins.str", + "MarginEngine": "class alphalab.portfolio.margin.MarginEngine()", + "MarketValueUpdated": "class alphalab.portfolio.events.MarketValueUpdated(timestamp: float, account_id: str, prices: collections.abc.Mapping[str, decimal.Decimal]) -> None", + "MissingRateError": "class alphalab.portfolio.fx.MissingRateError", + "MixedCurrencyValuationError": "class alphalab.portfolio.exceptions.MixedCurrencyValuationError", + "MultiStrategyBook": "class alphalab.portfolio.multi_strategy.MultiStrategyBook(portfolio_id: 'str', sleeves: 'tuple[StrategySleeve, ...]', unassigned_cash: 'CurrencyAmounts') -> None", + "NAVCalculator": "class alphalab.portfolio.nav.NAVCalculator()", + "NO_RATES": "value alphalab.portfolio.fx.FxRates", + "OptionExpiry": "class alphalab.portfolio.corporate_actions.OptionExpiry(asset_id: 'str', underlying_value: 'Decimal', reference: 'str' = '') -> None", + "PnLEngine": "class alphalab.portfolio.pnl.PnLEngine()", + "PortfolioEngine": "class alphalab.portfolio.engine.PortfolioEngine()", + "PortfolioEvent": "class alphalab.portfolio.events.PortfolioEvent(timestamp: float, account_id: str) -> None", + "PortfolioState": "class alphalab.portfolio.engine.PortfolioState(account: alphalab.portfolio.account.Account, cash: alphalab.portfolio.cash.CashLedger = , positions: collections.abc.Mapping[str, alphalab.portfolio.position.Position] = , ledger: alphalab.portfolio.ledger.TransactionLedger = , events: alphalab.common.append_log.AppendOnlyLog[alphalab.portfolio.events.PortfolioEvent] = , realized_pnl: alphalab.portfolio.amounts.CurrencyAmounts = , commission_paid: alphalab.portfolio.amounts.CurrencyAmounts = , pending_marks: tuple[str, ...] = ()) -> None", + "PortfolioValuation": "class alphalab.portfolio.valuation.PortfolioValuation()", + "PortfolioValuationSnapshot": "class alphalab.portfolio.valuation.PortfolioValuationSnapshot(timestamp: float, currency: str, cash: decimal.Decimal, long_value: decimal.Decimal, short_value: decimal.Decimal, positions_value: decimal.Decimal, unrealized_pnl: decimal.Decimal, realized_pnl: decimal.Decimal, commission_paid: decimal.Decimal, equity: decimal.Decimal, conversions: tuple[alphalab.portfolio.fx.FxConversion, ...] = ()) -> None", + "Position": "class alphalab.portfolio.position.Position(asset_id: 'str', quantity: 'Decimal', average_cost: 'Decimal', market_price: 'Decimal', realized_pnl: 'Decimal', currency: 'str', last_updated: 'float', cost_basis: 'Decimal | None' = None, opened_at: 'float | None' = None, minor_units: 'int | None' = None, economics: 'InstrumentEconomics | None' = None) -> None", + "PositionClosed": "class alphalab.portfolio.events.PositionClosed(timestamp: float, account_id: str, asset_id: str, price: decimal.Decimal, realized_pnl: decimal.Decimal) -> None", + "PositionIncreased": "class alphalab.portfolio.events.PositionIncreased(timestamp: float, account_id: str, asset_id: str, added_quantity: decimal.Decimal, price: decimal.Decimal) -> None", + "PositionOpened": "class alphalab.portfolio.events.PositionOpened(timestamp: float, account_id: str, asset_id: str, quantity: decimal.Decimal, price: decimal.Decimal) -> None", + "PositionReduced": "class alphalab.portfolio.events.PositionReduced(timestamp: float, account_id: str, asset_id: str, reduced_quantity: decimal.Decimal, price: decimal.Decimal, realized_pnl: decimal.Decimal) -> None", + "PositionSide": "enum alphalab.portfolio.types.PositionSide[LONG, SHORT, FLAT]", + "PositionSplit": "class alphalab.portfolio.events.PositionSplit(timestamp: float, account_id: str, asset_id: str, ratio: decimal.Decimal, quantity: decimal.Decimal) -> None", + "SettlementExposure": "class alphalab.portfolio.contracts.SettlementExposure(asset_id: 'str', quoted: 'ContractNotional', settled: 'Decimal', conversion: 'FxConversion | None') -> None", + "Split": "class alphalab.portfolio.corporate_actions.Split(asset_id: 'str', ratio: 'Decimal') -> None", + "StaleRateError": "class alphalab.portfolio.fx.StaleRateError", + "StrategySleeve": "class alphalab.portfolio.multi_strategy.StrategySleeve(strategy_id: 'str', positions: 'Mapping[str, Position]', cash: 'CurrencyAmounts', realized_pnl: 'CurrencyAmounts', commission_paid: 'CurrencyAmounts') -> None", + "StrategyValuation": "class alphalab.portfolio.multi_strategy.StrategyValuation(strategy_id: 'str', positions: 'int', long_exposure: 'Decimal', short_exposure: 'Decimal', net_exposure: 'Decimal', gross_exposure: 'Decimal', cash: 'Decimal', nav: 'Decimal', unrealized_pnl: 'Decimal', realized_pnl: 'CurrencyAmounts', reporting_realized_pnl: 'Decimal', commission_paid: 'CurrencyAmounts', reporting_commission_paid: 'Decimal') -> None", + "Transaction": "class alphalab.portfolio.transaction.Transaction(transaction_id: str, timestamp: float, account_id: str, type: alphalab.portfolio.types.TransactionType, asset_id: str, quantity: decimal.Decimal, price: decimal.Decimal, commission: decimal.Decimal, currency: str, metadata: collections.abc.Mapping[str, Any] = ) -> None", + "TransactionLedger": "class alphalab.portfolio.ledger.TransactionLedger(transactions: alphalab.common.append_log.AppendOnlyLog[alphalab.portfolio.transaction.Transaction] = ) -> None", + "TransactionType": "enum alphalab.portfolio.types.TransactionType[BUY, SELL, DIVIDEND, DEPOSIT, WITHDRAWAL, FEE, INTEREST, TRANSFER, VARIATION_MARGIN, FUNDING]", + "VariationSettled": "class alphalab.portfolio.events.VariationSettled(timestamp: float, account_id: str, asset_id: str, price: decimal.Decimal, amount: decimal.Decimal, currency: str) -> None", + "carry_rate": "function alphalab.portfolio.fx_research.carry_rate(terms: 'ForwardTerms') -> 'float'", + "cash_in": "function alphalab.portfolio.valuation.cash_in(cash: alphalab.portfolio.cash.CashLedger, base_currency: str, rates: alphalab.portfolio.fx.FxRates, as_of: float | None, units: alphalab.common.currency_units.CurrencyUnits | None = None) -> tuple[decimal.Decimal, tuple[alphalab.portfolio.fx.FxConversion, ...]]", + "contract_exposures": "function alphalab.portfolio.contracts.contract_exposures(holdings: 'Sequence[ContractHolding]') -> 'ContractExposure'", + "covered_forward_rate": "function alphalab.portfolio.fx_research.covered_forward_rate(spot: 'FxRate', terms: 'ForwardTerms') -> 'FxRate'", + "currency_attribution": "function alphalab.portfolio.fx_research.currency_attribution(opening: 'Mapping[str, Decimal]', closing: 'Mapping[str, Decimal]', opening_rates: 'FxRates', closing_rates: 'FxRates', reporting_currency: 'str', opening_timestamp: 'float', closing_timestamp: 'float') -> 'CurrencyAttributionReport'", + "currency_exposures": "function alphalab.portfolio.fx_research.currency_exposures(positions: 'Mapping[str, Position]') -> 'CurrencyAmounts'", + "foreign_currencies": "function alphalab.portfolio.valuation.foreign_currencies(cash: alphalab.portfolio.cash.CashLedger, positions: collections.abc.Mapping[str, alphalab.portfolio.position.Position], base_currency: str) -> tuple[tuple[str, ...], tuple[str, ...]]", + "forward_points": "function alphalab.portfolio.fx_research.forward_points(spot: 'FxRate', forward: 'FxRate') -> 'Decimal'", + "hedge_notional": "function alphalab.portfolio.fx_research.hedge_notional(exposure: 'Decimal', hedge_ratio: 'Decimal', currency: 'str', units: 'CurrencyUnits' = CurrencyUnits({})) -> 'Decimal'", + "holding_notional": "function alphalab.portfolio.contracts.holding_notional(holding: 'ContractHolding') -> 'ContractNotional'", + "settlement_exposures": "function alphalab.portfolio.contracts.settlement_exposures(holdings: 'Sequence[ContractHolding]', rates: 'FxRates', as_of: 'float') -> 'tuple[SettlementExposure, ...]'", + "value_book": "function alphalab.portfolio.multi_strategy.value_book(book: 'MultiStrategyBook', *, reporting_currency: 'str', rates: 'FxRates', as_of: 'float') -> 'BookValuation'" + }, + "alphalab.portfolio.fx_feed": { + "ConflictingQuoteError": "class alphalab.portfolio.fx_feed.ConflictingQuoteError", + "FX_FEED_SCHEMA_HISTORY": "value alphalab.persistence.upgrade.SchemaHistory", + "FX_FEED_SNAPSHOT_SCHEMA": "value builtins.int", + "FxFeed": "class alphalab.portfolio.fx_feed.FxFeed()", + "FxFeedDecision": "class alphalab.portfolio.fx_feed.FxFeedDecision(quote: 'FxQuote', outcome: 'FxFeedOutcome', previous: 'FxRate | None' = None) -> None", + "FxFeedOutcome": "enum alphalab.portfolio.fx_feed.FxFeedOutcome[APPLIED, DUPLICATE, SUPERSEDED]", + "FxFeedSnapshot": "class alphalab.portfolio.fx_feed.FxFeedSnapshot(feed_id: 'str', max_age_seconds: 'float | None', rates: 'tuple[FxRate, ...]', observed: 'int', applied_count: 'int', last_quote_at: 'float | None', decisions: 'tuple[FxFeedDecision, ...]', schema_version: 'int' = 1) -> None", + "FxFeedState": "class alphalab.portfolio.fx_feed.FxFeedState(feed_id: 'str', rates: 'FxRates' = , observed: 'int' = 0, applied_count: 'int' = 0, last_quote_at: 'float | None' = None, decisions: 'AppendOnlyLog[FxFeedDecision]' = ) -> None", + "FxQuote": "class alphalab.portfolio.fx_feed.FxQuote(rate: 'FxRate', sequence: 'int' = 0) -> None", + "FxRateSource": "class alphalab.portfolio.fx_feed.FxRateSource(*args, **kwargs)", + "SequenceFxSource": "class alphalab.portfolio.fx_feed.SequenceFxSource(source_id: 'str', rates: 'tuple[FxRate, ...]' = ()) -> None", + "capture": "function alphalab.portfolio.fx_feed.capture(state: 'FxFeedState') -> 'FxFeedSnapshot'", + "from_primitives": "function alphalab.portfolio.fx_feed.from_primitives(payload: 'Mapping[str, Any]') -> 'FxFeedSnapshot'", + "restore": "function alphalab.portfolio.fx_feed.restore(snapshot: 'FxFeedSnapshot') -> 'FxFeedState'" + }, + "alphalab.portfolio.snapshot": { + "PORTFOLIO_SCHEMA_HISTORY": "value alphalab.persistence.upgrade.SchemaHistory", + "PORTFOLIO_SNAPSHOT_SCHEMA": "value builtins.int", + "PortfolioEventRecord": "class alphalab.portfolio.snapshot.PortfolioEventRecord(event_type: 'str', event: 'PortfolioEvent') -> None", + "PortfolioSnapshot": "class alphalab.portfolio.snapshot.PortfolioSnapshot(account: 'Account', balances: 'Mapping[str, Decimal]', reserved: 'Mapping[str, Decimal]', positions: 'tuple[Position, ...]', transactions: 'tuple[Transaction, ...]', events: 'tuple[PortfolioEventRecord, ...]', realized_pnl: 'Mapping[str, Decimal]', commission_paid: 'Mapping[str, Decimal]', pending_marks: 'tuple[str, ...]', schema_version: 'int' = 5) -> None", + "capture": "function alphalab.portfolio.snapshot.capture(state: 'PortfolioState') -> 'PortfolioSnapshot'", + "from_primitives": "function alphalab.portfolio.snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'PortfolioSnapshot'", + "restore": "function alphalab.portfolio.snapshot.restore(snapshot: 'PortfolioSnapshot') -> 'PortfolioState'" + }, + "alphalab.portfolio_optimizer": { + "AllocationChanged": "class alphalab.portfolio_optimizer.events.AllocationChanged(event_id: str, timestamp: float, portfolio_id: str, new_capital: float) -> None", + "AlphaSignalProtocol": "class alphalab.portfolio_optimizer.protocol.AlphaSignalProtocol(*args, **kwargs)", + "BLACK_LITTERMAN_SCHEME": "value builtins.str", + "BlackLittermanModel": "class alphalab.portfolio_optimizer.black_litterman.BlackLittermanModel(covariance: 'CovarianceMatrix', prior: 'EquilibriumPrior | SuppliedPrior', tau: 'float', views: 'tuple[InvestorView, ...]') -> None", + "BlackLittermanPosterior": "class alphalab.portfolio_optimizer.black_litterman.BlackLittermanPosterior(model_id: 'str', prior_returns: 'ExpectedReturns', posterior_returns: 'ExpectedReturns', mean_uncertainty: 'CovarianceMatrix', posterior_covariance: 'CovarianceMatrix', views: 'tuple[ViewDiagnostic, ...]') -> None", + "BoxUncertainty": "class alphalab.portfolio_optimizer.construction.BoxUncertainty(half_widths: 'Mapping[str, float]') -> None", + "CONSTRUCTION_PROBLEM_SCHEME": "value builtins.str", + "CONSTRUCTION_RESULT_SCHEME": "value builtins.str", + "CapitalAllocation": "class alphalab.portfolio_optimizer.allocation.CapitalAllocation(portfolio_id: str, total_capital: float, invested_capital: float, cash_balance: float, margin_used: float, leverage_ratio: float) -> None", + "ConstraintSet": "class alphalab.portfolio_optimizer.construction.ConstraintSet(net_exposure: 'ExposureRange', bounds: 'WeightBounds', max_abs_weight: 'float | None' = None, max_gross_exposure: 'float | None' = None, groups: 'tuple[GroupBound, ...]' = (), factors: 'tuple[FactorBound, ...]' = (), turnover: 'TurnoverLimit | None' = None, notional_limits: 'NotionalLimits | None' = None, max_volatility: 'float | None' = None) -> None", + "ConstraintViolated": "class alphalab.portfolio_optimizer.events.ConstraintViolated(event_id: str, timestamp: float, portfolio_id: str, constraint_name: str, violation_amount: float) -> None", + "ConstraintViolationError": "class alphalab.portfolio_optimizer.exceptions.ConstraintViolationError", + "ConstructionDiagnostics": "class alphalab.portfolio_optimizer.construction.ConstructionDiagnostics(method: 'str', iterations: 'int', binding: 'tuple[str, ...]', conflict: 'tuple[str, ...]', max_violation: 'float', stationarity: 'float', covariance_pivot_ratio: 'float', variance: 'float | None', volatility: 'float | None', expected_return: 'float | None', worst_case_return: 'float | None', diversification_ratio: 'float | None', net_exposure: 'float | None', gross_exposure: 'float | None', turnover: 'float | None', risk: 'RiskContributions | None', factor_exposures: 'Mapping[str, float]', group_exposures: 'Mapping[str, float]', budget_deviation: 'float | None', effective_risk_aversion: 'float | None', detail: 'str', transaction_cost: 'float | None' = None) -> None", + "ConstructionInputError": "class alphalab.portfolio_optimizer.exceptions.ConstructionInputError", + "ConstructionObjective": "alias ConstructionObjective", + "ConstructionProblem": "class alphalab.portfolio_optimizer.construction.ConstructionProblem(covariance: 'CovarianceMatrix | FactorStructure', objective: 'ConstructionObjective', constraints: 'ConstraintSet', settings: 'SolverSettings') -> None", + "ConstructionResult": "class alphalab.portfolio_optimizer.construction.ConstructionResult(problem_id: 'str', status: 'ConstructionStatus', weights: 'Mapping[str, float] | None', diagnostics: 'ConstructionDiagnostics') -> None", + "ConstructionStatus": "enum alphalab.portfolio_optimizer.construction.ConstructionStatus[OPTIMAL, INFEASIBLE, ITERATION_LIMIT, NUMERICAL_FAILURE]", + "CostModel": "class alphalab.portfolio_optimizer.costs.CostModel(commission_rate: float, slippage_rate: float, spread_rate: float, market_impact_rate: float, fixed_exchange_fee: float) -> None", + "EXPECTED_RETURNS_SCHEME": "value builtins.str", + "EllipsoidalUncertainty": "class alphalab.portfolio_optimizer.construction.EllipsoidalUncertainty(omega: 'CovarianceMatrix', radius: 'float') -> None", + "EquilibriumPrior": "class alphalab.portfolio_optimizer.black_litterman.EquilibriumPrior(market_values: 'Mapping[str, Decimal]', currency: 'str', risk_aversion: 'float') -> None", + "ExpectedReturns": "class alphalab.portfolio_optimizer.construction.ExpectedReturns(values: 'Mapping[str, float]', currency: 'str', period: 'str', source: 'str') -> None", + "ExposureRange": "class alphalab.portfolio_optimizer.construction.ExposureRange(lower: 'float | None', upper: 'float | None') -> None", + "ExposureUpdated": "class alphalab.portfolio_optimizer.events.ExposureUpdated(event_id: str, timestamp: float, portfolio_id: str) -> None", + "FactorBound": "class alphalab.portfolio_optimizer.construction.FactorBound(loadings: 'FactorLoadings', factor: 'str', lower: 'float | None', upper: 'float | None') -> None", + "GroupBound": "class alphalab.portfolio_optimizer.construction.GroupBound(classification: 'Classification', label: 'str', lower: 'float | None', upper: 'float | None') -> None", + "InvalidPortfolioStateError": "class alphalab.portfolio_optimizer.exceptions.InvalidPortfolioStateError", + "InvestorView": "class alphalab.portfolio_optimizer.black_litterman.InvestorView(name: 'str', exposures: 'Mapping[str, float]', expected_return: 'float', variance: 'float') -> None", + "LinearCosts": "class alphalab.portfolio_optimizer.construction.LinearCosts(current: 'Mapping[str, float]', rates: 'Mapping[str, float]') -> None", + "LotRounding": "class alphalab.portfolio_optimizer.lots.LotRounding(quantities: 'Mapping[str, Decimal]', notionals: 'Mapping[str, Decimal]', weights: 'Mapping[str, float]', residuals: 'Mapping[str, float]', below_one_lot: 'tuple[str, ...]') -> None", + "MaximumDiversification": "class alphalab.portfolio_optimizer.construction.MaximumDiversification() -> None", + "MeanVariance": "class alphalab.portfolio_optimizer.construction.MeanVariance(expected_returns: 'ExpectedReturns', risk_aversion: 'float', costs: 'LinearCosts | None' = None) -> None", + "MinimumVariance": "class alphalab.portfolio_optimizer.construction.MinimumVariance() -> None", + "NotionalLimits": "class alphalab.portfolio_optimizer.construction.NotionalLimits(capital: 'Decimal', maximum_notional: 'Mapping[str, Decimal]') -> None", + "OptimizationError": "class alphalab.portfolio_optimizer.exceptions.OptimizationError", + "Portfolio": "class alphalab.portfolio_optimizer.targets.Portfolio(portfolio_id: str, name: str, base_currency: str, created_at: float, is_active: bool = True) -> None", + "PortfolioAdapter": "class alphalab.portfolio_optimizer.adapter.PortfolioAdapter()", + "PortfolioCreated": "class alphalab.portfolio_optimizer.events.PortfolioCreated(event_id: str, timestamp: float, portfolio_id: str) -> None", + "PortfolioEngine": "class alphalab.portfolio_optimizer.engine.PortfolioEngine()", + "PortfolioEngineError": "class alphalab.portfolio_optimizer.exceptions.PortfolioEngineError", + "PortfolioEngineState": "class alphalab.portfolio_optimizer.state.PortfolioEngineState(engine_id: str, portfolios: alphalab.common.persistent_map.PersistentMap[str, alphalab.portfolio_optimizer.targets.Portfolio] = , weights: alphalab.common.persistent_map.PersistentMap[str, alphalab.portfolio_optimizer.weights.TargetWeights] = , constraints: alphalab.common.persistent_map.PersistentMap[str, alphalab.portfolio_optimizer.constraints.WeightConstraints] = , metrics: alphalab.common.persistent_map.PersistentMap[str, alphalab.portfolio_optimizer.metrics.PortfolioMetrics] = , exposures: alphalab.common.persistent_map.PersistentMap[str, alphalab.portfolio_optimizer.exposure.PortfolioExposure] = , allocations: alphalab.common.persistent_map.PersistentMap[str, alphalab.portfolio_optimizer.allocation.CapitalAllocation] = , cost_estimates: alphalab.common.persistent_map.PersistentMap[str, alphalab.portfolio_optimizer.costs.TransactionCostEstimate] = , events: alphalab.common.append_log.AppendOnlyLog[alphalab.portfolio_optimizer.events.PortfolioEvent] = ) -> None", + "PortfolioEvent": "class alphalab.portfolio_optimizer.events.PortfolioEvent(event_id: str, timestamp: float) -> None", + "PortfolioExposure": "class alphalab.portfolio_optimizer.exposure.PortfolioExposure(portfolio_id: str, timestamp: float, sector_exposure: collections.abc.Mapping[str, float] = , country_exposure: collections.abc.Mapping[str, float] = , factor_exposure: collections.abc.Mapping[str, float] = , net_beta: float = 0.0, gross_exposure: float = 0.0, net_exposure: float = 0.0) -> None", + "PortfolioMetrics": "class alphalab.portfolio_optimizer.metrics.PortfolioMetrics(portfolio_id: str, timestamp: float, total_return: float, annual_return: float, volatility: float, sharpe_ratio: float, sortino_ratio: float, calmar_ratio: float, max_drawdown: float, turnover: float, diversification_ratio: float) -> None", + "PortfolioUpdated": "class alphalab.portfolio_optimizer.events.PortfolioUpdated(event_id: str, timestamp: float, portfolio_id: str) -> None", + "PortfolioValidationError": "class alphalab.portfolio_optimizer.exceptions.PortfolioValidationError", + "RebalanceTrigger": "enum alphalab.portfolio_optimizer.rebalance.RebalanceTrigger[DAILY, WEEKLY, MONTHLY, THRESHOLD, VOLATILITY, CUSTOM]", + "Rebalanced": "class alphalab.portfolio_optimizer.events.Rebalanced(event_id: str, timestamp: float, portfolio_id: str, trigger: str) -> None", + "RiskConstraints": "class alphalab.portfolio_optimizer.constraints.RiskConstraints(max_drawdown_limit: float, max_volatility_limit: float, max_turnover: float) -> None", + "RiskModelProtocol": "class alphalab.portfolio_optimizer.protocol.RiskModelProtocol(*args, **kwargs)", + "RiskParity": "class alphalab.portfolio_optimizer.construction.RiskParity(budgets: 'Mapping[str, Decimal] | None') -> None", + "RobustMeanVariance": "class alphalab.portfolio_optimizer.construction.RobustMeanVariance(expected_returns: 'ExpectedReturns', risk_aversion: 'float', uncertainty: 'EllipsoidalUncertainty | BoxUncertainty') -> None", + "SolverSettings": "class alphalab.portfolio_optimizer.construction.SolverSettings(feasibility_tolerance: 'float', convergence_tolerance: 'float', max_iterations: 'int') -> None", + "SuppliedPrior": "class alphalab.portfolio_optimizer.black_litterman.SuppliedPrior(returns: 'ExpectedReturns') -> None", + "TargetTransaction": "class alphalab.portfolio_optimizer.transactions.TargetTransaction(symbol: str, current_weight: float, target_weight: float, trade_weight: float, estimated_cost: float) -> None", + "TargetWeights": "class alphalab.portfolio_optimizer.weights.TargetWeights(portfolio_id: str, timestamp: float, weights: collections.abc.Mapping[str, float] = ) -> None", + "TransactionCostEstimate": "class alphalab.portfolio_optimizer.costs.TransactionCostEstimate(portfolio_id: str, total_trade_value: float, estimated_commission: float, estimated_slippage: float, estimated_market_impact: float, total_estimated_cost: float, estimated_spread: float = 0.0) -> None", + "TurnoverLimit": "class alphalab.portfolio_optimizer.construction.TurnoverLimit(current: 'Mapping[str, float]', maximum: 'float') -> None", + "ViewDiagnostic": "class alphalab.portfolio_optimizer.black_litterman.ViewDiagnostic(name: 'str', stated: 'float', prior_implied: 'float', posterior_implied: 'float', variance: 'float') -> None", + "WeightBounds": "class alphalab.portfolio_optimizer.construction.WeightBounds(lower: 'float | None', upper: 'float | None', overrides: 'Mapping[str, tuple[float | None, float | None]]' = ) -> None", + "WeightConstraints": "class alphalab.portfolio_optimizer.constraints.WeightConstraints(long_only: bool = True, max_position_weight: float = 1.0, min_position_weight: float = -1.0, cash_reserve_weight: float = 0.0, max_sector_exposure: collections.abc.Mapping[str, float] = , max_asset_exposure: float = 1.0) -> None", + "WeightsCalculated": "class alphalab.portfolio_optimizer.events.WeightsCalculated(event_id: str, timestamp: float, portfolio_id: str, optimization_method: str) -> None", + "allocation_report": "function alphalab.portfolio_optimizer.views.allocation_report(state: alphalab.portfolio_optimizer.state.PortfolioEngineState, port_id: str) -> alphalab.portfolio_optimizer.allocation.CapitalAllocation | None", + "apply_weight_constraints": "function alphalab.portfolio_optimizer.constraints.apply_weight_constraints(raw_weights: collections.abc.Mapping[str, float], constraints: alphalab.portfolio_optimizer.constraints.WeightConstraints) -> dict[str, float]", + "black_litterman": "function alphalab.portfolio_optimizer.black_litterman.black_litterman(model: 'BlackLittermanModel') -> 'BlackLittermanPosterior'", + "calculate_max_drawdown": "function alphalab.common.statistics.compounded_max_drawdown(returns: 'Sequence[float]') -> 'float'", + "check_schedule_rebalance": "function alphalab.portfolio_optimizer.rebalance.check_schedule_rebalance(last_rebalance: float, current_time: float, trigger: alphalab.portfolio_optimizer.rebalance.RebalanceTrigger) -> bool", + "check_threshold_rebalance": "function alphalab.portfolio_optimizer.rebalance.check_threshold_rebalance(current_weights: collections.abc.Mapping[str, float], target_weights: collections.abc.Mapping[str, float], threshold: float = 0.05) -> bool", + "construct": "function alphalab.portfolio_optimizer.construction.construct(problem: 'ConstructionProblem') -> 'ConstructionResult'", + "expected_costs": "function alphalab.portfolio_optimizer.views.expected_costs(state: alphalab.portfolio_optimizer.state.PortfolioEngineState, port_id: str) -> alphalab.portfolio_optimizer.costs.TransactionCostEstimate | None", + "exposure_report": "function alphalab.portfolio_optimizer.views.exposure_report(state: alphalab.portfolio_optimizer.state.PortfolioEngineState, port_id: str) -> alphalab.portfolio_optimizer.exposure.PortfolioExposure | None", + "optimize_equal_weight": "function alphalab.portfolio_optimizer.optimizer.optimize_equal_weight(symbols: collections.abc.Sequence[str]) -> dict[str, float]", + "optimize_inverse_volatility": "function alphalab.portfolio_optimizer.optimizer.optimize_inverse_volatility(symbols: collections.abc.Sequence[str], volatilities: collections.abc.Mapping[str, float]) -> dict[str, float]", + "optimize_maximum_sharpe": "function alphalab.portfolio_optimizer.optimizer.optimize_maximum_sharpe(symbols: collections.abc.Sequence[str], expected_returns: collections.abc.Sequence[float], covariance_matrix: collections.abc.Sequence[collections.abc.Sequence[float]]) -> dict[str, float]", + "optimize_minimum_variance": "function alphalab.portfolio_optimizer.optimizer.optimize_minimum_variance(symbols: collections.abc.Sequence[str], covariance_matrix: collections.abc.Sequence[collections.abc.Sequence[float]]) -> dict[str, float]", + "portfolio_metrics": "function alphalab.portfolio_optimizer.views.portfolio_metrics(state: alphalab.portfolio_optimizer.state.PortfolioEngineState, port_id: str) -> alphalab.portfolio_optimizer.metrics.PortfolioMetrics | None", + "portfolio_summary": "function alphalab.portfolio_optimizer.views.portfolio_summary(state: alphalab.portfolio_optimizer.state.PortfolioEngineState) -> collections.abc.Sequence[alphalab.portfolio_optimizer.targets.Portfolio]", + "round_to_lots": "function alphalab.portfolio_optimizer.lots.round_to_lots(weights: 'Mapping[str, float]', *, capital: 'Decimal', prices: 'Mapping[str, Decimal]', economics: 'Mapping[str, InstrumentEconomics]', quantum: 'Decimal') -> 'LotRounding'", + "validate_portfolio_creation": "function alphalab.portfolio_optimizer.validation.validate_portfolio_creation(state: alphalab.portfolio_optimizer.state.PortfolioEngineState, portfolio: alphalab.portfolio_optimizer.targets.Portfolio) -> None", + "validate_portfolio_exists": "function alphalab.portfolio_optimizer.validation.validate_portfolio_exists(state: alphalab.portfolio_optimizer.state.PortfolioEngineState, portfolio_id: str) -> None", + "validate_risk_constraints": "function alphalab.portfolio_optimizer.risk.validate_risk_constraints(metrics: alphalab.portfolio_optimizer.metrics.PortfolioMetrics, constraints: alphalab.portfolio_optimizer.constraints.RiskConstraints) -> None", + "view_variance_from_prior": "function alphalab.portfolio_optimizer.black_litterman.view_variance_from_prior(exposures: 'Mapping[str, float]', covariance: 'CovarianceMatrix', tau: 'float', scale: 'float') -> 'float'", + "weight_breakdown": "function alphalab.portfolio_optimizer.views.weight_breakdown(state: alphalab.portfolio_optimizer.state.PortfolioEngineState, port_id: str) -> alphalab.portfolio_optimizer.weights.TargetWeights | None" + }, + "alphalab.reinforcement_learning": { + "Action": "enum alphalab.reinforcement_learning.action.Action[HOLD, BUY, SELL]", + "QTable": "class alphalab.reinforcement_learning.q_learning.QTable(values: collections.abc.Mapping[tuple[tuple[str, str], alphalab.reinforcement_learning.action.Action], float] = ) -> None", + "RLAgentStrategy": "class alphalab.reinforcement_learning.environment.RLAgentStrategy()", + "RLError": "class alphalab.reinforcement_learning.exceptions.RLError", + "RLInputError": "class alphalab.reinforcement_learning.exceptions.RLInputError", + "State": "alias tuple[str, str]", + "StepResult": "class alphalab.reinforcement_learning.environment.StepResult(state: alphalab.reinforcement_learning.environment.TradingEnvState, reward: decimal.Decimal, done: bool, info: collections.abc.Mapping[str, Any]) -> None", + "TradingEnvConfig": "class alphalab.reinforcement_learning.environment.TradingEnvConfig(asset_id: str, strategy_id: str, trade_size: decimal.Decimal, starting_cash: decimal.Decimal, currency: str) -> None", + "TradingEnvState": "class alphalab.reinforcement_learning.environment.TradingEnvState(config: alphalab.reinforcement_learning.environment.TradingEnvConfig, pipeline: alphalab.runtime.execution_pipeline.ExecutionPipelineState, equity: decimal.Decimal, step_count: int) -> None", + "Trajectory": "class alphalab.reinforcement_learning.policy.Trajectory(states: tuple[tuple[float, ...], ...], actions: tuple[int, ...], rewards: tuple[float, ...]) -> None", + "action_to_signed_quantity": "function alphalab.reinforcement_learning.action.action_to_signed_quantity(action: alphalab.reinforcement_learning.action.Action, trade_size: decimal.Decimal) -> decimal.Decimal", + "average_reward": "function alphalab.reinforcement_learning.evaluation.average_reward(rewards: tuple[float, ...]) -> float", + "best_action": "function alphalab.reinforcement_learning.q_learning.best_action(table: alphalab.reinforcement_learning.q_learning.QTable, state: tuple[str, str], actions: tuple[alphalab.reinforcement_learning.action.Action, ...] = (, , )) -> alphalab.reinforcement_learning.action.Action", + "create_environment": "function alphalab.reinforcement_learning.environment.create_environment(config: alphalab.reinforcement_learning.environment.TradingEnvConfig, timestamp: float) -> alphalab.reinforcement_learning.environment.TradingEnvState", + "cumulative_reward": "function alphalab.reinforcement_learning.evaluation.cumulative_reward(rewards: tuple[float, ...]) -> float", + "current_position": "function alphalab.reinforcement_learning.environment.current_position(state: alphalab.reinforcement_learning.environment.TradingEnvState) -> decimal.Decimal", + "discounted_returns": "function alphalab.reinforcement_learning.policy.discounted_returns(rewards: tuple[float, ...], discount: float) -> tuple[float, ...]", + "discretize_state": "function alphalab.reinforcement_learning.q_learning.discretize_state(position: decimal.Decimal, price_change_pct: decimal.Decimal) -> tuple[str, str]", + "max_drawdown": "function alphalab.reinforcement_learning.evaluation.max_drawdown(rewards: tuple[float, ...]) -> float", + "policy_probabilities": "function alphalab.reinforcement_learning.policy.policy_probabilities(network: alphalab.deep_learning.network.Sequential, state: tuple[float, ...]) -> tuple[float, ...]", + "q_update": "function alphalab.reinforcement_learning.q_learning.q_update(table: alphalab.reinforcement_learning.q_learning.QTable, state: tuple[str, str], action: alphalab.reinforcement_learning.action.Action, reward: float, next_state: tuple[str, str], learning_rate: float, discount: float, actions: tuple[alphalab.reinforcement_learning.action.Action, ...] = (, , )) -> alphalab.reinforcement_learning.q_learning.QTable", + "q_value": "function alphalab.reinforcement_learning.q_learning.q_value(table: alphalab.reinforcement_learning.q_learning.QTable, state: tuple[str, str], action: alphalab.reinforcement_learning.action.Action) -> float", + "reinforce_update": "function alphalab.reinforcement_learning.policy.reinforce_update(network: alphalab.deep_learning.network.Sequential, trajectory: alphalab.reinforcement_learning.policy.Trajectory, learning_rate: float, discount: float) -> tuple[alphalab.deep_learning.network.Sequential, float]", + "reward_volatility": "function alphalab.reinforcement_learning.evaluation.reward_volatility(rewards: tuple[float, ...]) -> float", + "sample_action": "function alphalab.reinforcement_learning.policy.sample_action(probabilities: tuple[float, ...], rng: random.Random) -> int", + "sharpe_like_ratio": "function alphalab.reinforcement_learning.evaluation.sharpe_like_ratio(rewards: tuple[float, ...]) -> float", + "step_environment": "function alphalab.reinforcement_learning.environment.step_environment(state: alphalab.reinforcement_learning.environment.TradingEnvState, action: alphalab.reinforcement_learning.action.Action, price: decimal.Decimal, timestamp: float) -> alphalab.reinforcement_learning.environment.StepResult", + "win_rate": "function alphalab.reinforcement_learning.evaluation.win_rate(rewards: tuple[float, ...]) -> float" + }, + "alphalab.replay": { + "DataLoaderProtocol": "class alphalab.replay.loader.DataLoaderProtocol(*args, **kwargs)", + "HistoricalEventProtocol": "class alphalab.replay.loader.HistoricalEventProtocol(*args, **kwargs)", + "InvalidReplayStateError": "class alphalab.replay.exceptions.InvalidReplayStateError", + "ReplayAdvanced": "class alphalab.replay.events.ReplayAdvanced(event_id: str, timestamp: float, old_timestamp: float, new_timestamp: float, events_yielded: int) -> None", + "ReplayBatchResult": "class alphalab.replay.replay.ReplayBatchResult(state: alphalab.replay.state.ReplayState, events: tuple[alphalab.replay.loader.HistoricalEventProtocol, ...]) -> None", + "ReplayCompleted": "class alphalab.replay.events.ReplayCompleted(event_id: str, timestamp: float, session_id: str) -> None", + "ReplayEngine": "class alphalab.replay.engine.ReplayEngine()", + "ReplayError": "class alphalab.replay.exceptions.ReplayError", + "ReplayMetrics": "class alphalab.replay.metrics.ReplayMetrics(events_processed: int, total_events: int, elapsed_replay_time: float, elapsed_real_time: float) -> None", + "ReplayPaused": "class alphalab.replay.events.ReplayPaused(event_id: str, timestamp: float, session_id: str) -> None", + "ReplayReset": "class alphalab.replay.events.ReplayReset(event_id: str, timestamp: float, reset_time: float) -> None", + "ReplayResumed": "class alphalab.replay.events.ReplayResumed(event_id: str, timestamp: float, session_id: str) -> None", + "ReplaySession": "class alphalab.replay.session.ReplaySession(session_id: str, start_time: float, end_time: float, speed_multiplier: float = 1.0) -> None", + "ReplayStarted": "class alphalab.replay.events.ReplayStarted(event_id: str, timestamp: float, session_id: str, total_events: int) -> None", + "ReplayState": "class alphalab.replay.state.ReplayState(session: alphalab.replay.session.ReplaySession, status: alphalab.replay.state.ReplayStatus, events: tuple[alphalab.replay.loader.HistoricalEventProtocol, ...], current_index: int, current_timestamp: float, real_start_time: float, real_current_time: float, system_events: alphalab.common.append_log.AppendOnlyLog[alphalab.replay.events.ReplaySystemEvent] = ) -> None", + "ReplayStatus": "enum alphalab.replay.state.ReplayStatus[READY, RUNNING, PAUSED, STOPPED, COMPLETED]", + "ReplayStepResult": "class alphalab.replay.replay.ReplayStepResult(state: alphalab.replay.state.ReplayState, event: alphalab.replay.loader.HistoricalEventProtocol | None) -> None", + "ReplayStopped": "class alphalab.replay.events.ReplayStopped(event_id: str, timestamp: float, session_id: str) -> None", + "ReplaySystemEvent": "class alphalab.replay.events.ReplaySystemEvent(event_id: str, timestamp: float) -> None", + "ReplayValidationError": "class alphalab.replay.exceptions.ReplayValidationError", + "completion_ratio": "function alphalab.replay.views.completion_ratio(state: alphalab.replay.state.ReplayState) -> decimal.Decimal", + "current_throughput": "function alphalab.replay.views.current_throughput(state: alphalab.replay.state.ReplayState) -> float", + "current_timestamp": "function alphalab.replay.views.current_timestamp(state: alphalab.replay.state.ReplayState) -> float", + "elapsed_real_time": "function alphalab.replay.views.elapsed_real_time(state: alphalab.replay.state.ReplayState) -> float", + "processed_events": "function alphalab.replay.views.processed_events(state: alphalab.replay.state.ReplayState) -> int", + "remaining_events": "function alphalab.replay.views.remaining_events(state: alphalab.replay.state.ReplayState) -> int", + "validate_events": "function alphalab.replay.validation.validate_events(events: tuple[alphalab.replay.loader.HistoricalEventProtocol, ...]) -> None", + "validate_session": "function alphalab.replay.validation.validate_session(session: alphalab.replay.session.ReplaySession) -> None" + }, + "alphalab.reporting": { + "ExportCompleted": "class alphalab.reporting.events.ExportCompleted(event_id: str, timestamp: float, report_id: str, export_format: str, byte_size: int) -> None", + "ExportError": "class alphalab.reporting.exceptions.ExportError", + "ExportFailed": "class alphalab.reporting.events.ExportFailed(event_id: str, timestamp: float, report_id: str, export_format: str, reason: str) -> None", + "ExportProtocol": "class alphalab.reporting.protocol.ExportProtocol(*args, **kwargs)", + "Report": "class alphalab.reporting.report.Report(report_id: str, title: str, timestamp: float, report_type: alphalab.reporting.report.ReportType, sections: tuple[alphalab.reporting.sections.ReportSection, ...], summary: str = '', metadata: collections.abc.Mapping[str, str] = ) -> None", + "ReportGenerated": "class alphalab.reporting.events.ReportGenerated(event_id: str, timestamp: float, report_id: str, report_type: str) -> None", + "ReportSection": "class alphalab.reporting.sections.ReportSection(name: str, section_type: alphalab.reporting.sections.ReportSectionType, content: Any, description: str = '') -> None", + "ReportSectionType": "enum alphalab.reporting.sections.ReportSectionType[TEXT, METRICS, TABLE, CHART_METADATA]", + "ReportType": "enum alphalab.reporting.report.ReportType[PERFORMANCE, RISK, PORTFOLIO, TRADE, EXECUTION, OPTIMIZATION, RUNTIME]", + "ReportingAdapter": "class alphalab.reporting.adapter.ReportingAdapter()", + "ReportingEngine": "class alphalab.reporting.engine.ReportingEngine()", + "ReportingError": "class alphalab.reporting.exceptions.ReportingError", + "ReportingEvent": "class alphalab.reporting.events.ReportingEvent(event_id: str, timestamp: float) -> None", + "ReportingState": "class alphalab.reporting.state.ReportingState(engine_id: str, reports: alphalab.common.persistent_map.PersistentMap[str, alphalab.reporting.report.Report] = , exports: alphalab.common.persistent_map.PersistentMap[str, str] = , statistics: alphalab.reporting.state.ReportingStatistics = , events: alphalab.common.append_log.AppendOnlyLog[alphalab.reporting.events.ReportingEvent] = , metadata: collections.abc.Mapping[str, str] = ) -> None", + "ReportingStatistics": "class alphalab.reporting.state.ReportingStatistics(total_reports_generated: int = 0, total_exports_completed: int = 0, total_exports_failed: int = 0) -> None", + "ReportingValidationError": "class alphalab.reporting.exceptions.ReportingValidationError", + "export_csv": "function alphalab.reporting.export.export_csv(report: alphalab.reporting.report.Report) -> str", + "export_json": "function alphalab.reporting.export.export_json(report: alphalab.reporting.report.Report) -> str", + "export_markdown": "function alphalab.reporting.export.export_markdown(report: alphalab.reporting.report.Report) -> str", + "export_statistics": "function alphalab.reporting.views.export_statistics(state: alphalab.reporting.state.ReportingState) -> alphalab.reporting.state.ReportingStatistics", + "get_export": "function alphalab.reporting.views.get_export(state: alphalab.reporting.state.ReportingState, report_id: str, format_type: str) -> str | None", + "latest_report": "function alphalab.reporting.views.latest_report(state: alphalab.reporting.state.ReportingState) -> alphalab.reporting.report.Report | None", + "report_count": "function alphalab.reporting.views.report_count(state: alphalab.reporting.state.ReportingState) -> int", + "validate_report": "function alphalab.reporting.validation.validate_report(state: alphalab.reporting.state.ReportingState, report: alphalab.reporting.report.Report) -> None" + }, + "alphalab.research": { + "AbnormalReturnModel": "enum alphalab.research.event_study.AbnormalReturnModel[RAW, MARKET_ADJUSTED, MEAN_ADJUSTED]", + "AnalysisCompleted": "class alphalab.research.events.AnalysisCompleted(event_id: str, timestamp: float, research_id: str, analysis_type: str) -> None", + "BiasReport": "class alphalab.research.bias.BiasReport(win_rate: float | None, mean_trade_duration_seconds: float | None, annualized_volatility: float | None, parameter_count: int, trade_count: int, observations: int, trades_per_parameter: float | None) -> None", + "BootstrapReport": "class alphalab.research.bootstrap.BootstrapReport(metric: str, iterations: int, lower_bound_5th: float | None, median_50th: float | None, upper_bound_95th: float | None) -> None", + "CVMethod": "enum alphalab.research.time_series_cv.CVMethod[ROLLING, EXPANDING, PURGED, EMBARGOED]", + "CapacityReport": "class alphalab.research.capacity.CapacityReport(base_aum: float, base_cagr: float | None, trade_count: int, trades_per_year: float | None) -> None", + "CompositeRule": "class alphalab.research.regimes.CompositeRule(components: 'tuple[ComponentRule, ...]', cells: 'tuple[RegimeCell, ...]') -> None", + "Correction": "enum alphalab.research.multiple_testing.Correction[BONFERRONI, HOLM, BENJAMINI_HOCHBERG, BENJAMINI_YEKUTIELI]", + "DeflatedSharpe": "class alphalab.research.sharpe_inference.DeflatedSharpe(inference: 'SharpeInference', independent_trials: 'int', trial_sharpe_variance: 'float', expected_maximum: 'float') -> None", + "DiagnosticReport": "class alphalab.research.diagnostics.DiagnosticReport(too_few_trades: bool, high_concentration: bool, large_tail_risk: bool, warnings: tuple[str, ...]) -> None", + "DiagnosticsGenerated": "class alphalab.research.events.DiagnosticsGenerated(event_id: str, timestamp: float, research_id: str, warning_count: int) -> None", + "EVENT_STUDY_SCHEME": "value builtins.str", + "ErrorRate": "enum alphalab.research.multiple_testing.ErrorRate[FAMILY_WISE, FALSE_DISCOVERY]", + "EventOutcome": "class alphalab.research.event_study.EventOutcome(event_id: 'str', subject: 'str', event_type: 'str', known_at: 'float', tradable_at: 'float', timing: 'SessionTiming', anchor: 'float', abnormal_returns: 'tuple[float, ...]', cumulative: 'float', pre_event: 'float', post_event: 'float') -> None", + "EventStudyDefinition": "class alphalab.research.event_study.EventStudyDefinition(name: 'str', event_types: 'tuple[str, ...]', window: 'EventWindow', model: 'AbnormalReturnModel', benchmark: 'str | None', visibility: 'VisibilityRule') -> None", + "EventStudyResult": "class alphalab.research.event_study.EventStudyResult(definition: 'EventStudyDefinition', event_set_version: 'str', prices_version: 'str | None', calendar_id: 'str', outcomes: 'tuple[EventOutcome, ...]', excluded: 'tuple[ExcludedEvent, ...]', average_abnormal: 'tuple[float, ...]', mean_cumulative: 'float', median_cumulative: 'float', cumulative_std: 'float | None', positive_share: 'float', shared_anchor_events: 'int', result_id: 'str') -> None", + "EventWindow": "class alphalab.research.event_study.EventWindow(before: 'int', after: 'int', estimation: 'int', gap: 'int') -> None", + "ExcludedEvent": "class alphalab.research.event_study.ExcludedEvent(event_id: 'str', subject: 'str', reason: 'str') -> None", + "FoldSelection": "class alphalab.research.walk_forward_optimization.FoldSelection(fold: 'int', selected: 'str', parameters: 'Mapping[str, ParamValue]', position: 'int', surface: 'SweepResult', validation_score: 'float', test_score: 'float', degradation: 'float | None') -> None", + "InvalidResearchStateError": "class alphalab.research.exceptions.InvalidResearchStateError", + "MonteCarloReport": "class alphalab.research.montecarlo.MonteCarloReport(simulations: int, median_drawdown: float | None, percentile_95_drawdown: float | None, worst_drawdown: float | None, ruin_drawdown: float, ruin_probability: float | None) -> None", + "MultipleTestingResult": "class alphalab.research.multiple_testing.MultipleTestingResult(correction: 'Correction', alpha: 'float', p_values: 'Mapping[str, float]', adjusted: 'Mapping[str, float]', rejected: 'tuple[str, ...]') -> None", + "OverfittingPolicy": "class alphalab.research.overfitting.OverfittingPolicy(maximum_degradation: 'float | None' = None, maximum_sensitivity: 'float | None' = None, maximum_neighbour_drop: 'float | None' = None, minimum_positive_share: 'float | None' = None, alpha: 'float' = 0.05) -> None", + "OverfittingReport": "class alphalab.research.overfitting.OverfittingReport(metric: 'str', trials: 'int', in_sample: 'float', out_of_sample: 'float', degradation: 'float | None', sensitivity: 'float | None', neighbour_drop: 'float | None', effective_parameters: 'int', policy: 'OverfittingPolicy', period: 'StabilityReport | None' = None, symbol: 'StabilityReport | None' = None, findings: 'tuple[str, ...]' = ) -> None", + "ParameterSpace": "class alphalab.research.walk_forward_optimization.ParameterSpace(candidates: 'tuple[Mapping[str, ParamValue], ...]') -> None", + "Perturbation": "class alphalab.research.perturbation.Perturbation(kind: 'PerturbationKind', magnitude: 'float', seed: 'int | None' = None, detail: 'str' = '') -> None", + "PerturbationKind": "enum alphalab.research.perturbation.PerturbationKind[PARAMETER, DATA, SIGNAL, MISSING_DATA, EXECUTION_DELAY, EXECUTION_COST, BLOCK_BOOTSTRAP, MONTE_CARLO]", + "PerturbationRun": "class alphalab.research.perturbation.PerturbationRun(perturbation: 'Perturbation', baseline: 'str', dataset_version: 'str | None', metrics: 'Mapping[str, float]' = , baseline_metrics: 'Mapping[str, float]' = ) -> None", + "PurgePolicy": "class alphalab.research.purging.PurgePolicy(label_ends: 'Mapping[float, float]', embargo_seconds: 'float' = 0.0) -> None", + "PurgeResult": "class alphalab.research.purging.PurgeResult(kept: 'tuple[float, ...]', purged: 'tuple[float, ...]', embargoed: 'tuple[float, ...]') -> None", + "QuantileBucket": "class alphalab.research.signals.QuantileBucket(bucket: 'int', observations: 'int', instants: 'int', mean_forward_return: 'float', median_forward_return: 'float') -> None", + "REGIME_DEFINITION_SCHEME": "value builtins.str", + "REGIME_SERIES_SCHEME": "value builtins.str", + "RESULT_KEY_SCHEME": "value builtins.str", + "Refit": "enum alphalab.research.walk_forward_optimization.Refit[TRAIN, TRAIN_AND_VALIDATION]", + "RegimeCell": "class alphalab.research.regimes.RegimeCell(components: 'tuple[str, ...]', regime: 'str') -> None", + "RegimeDefinition": "class alphalab.research.regimes.RegimeDefinition(name: 'str', rule: 'RegimeRule', persistence: 'int') -> None", + "RegimeProfile": "class alphalab.research.regimes.RegimeProfile(series_id: 'str', classified: 'int', unclassified: 'int', statistics: 'tuple[RegimeStatistics, ...]', transitions: 'tuple[TransitionFrequency, ...]') -> None", + "RegimeReport": "class alphalab.research.regime.RegimeReport(observations_by_regime: collections.abc.Mapping[str, int], sharpe_by_regime: collections.abc.Mapping[str, float]) -> None", + "RegimeRule": "alias RegimeRule", + "RegimeSeries": "class alphalab.research.regimes.RegimeSeries(definition: 'RegimeDefinition', input_id: 'str | None', initial_state_id: 'str', timestamps: 'tuple[float, ...]', raw_labels: 'tuple[str | None, ...]', regimes: 'tuple[str | None, ...]', transitions: 'tuple[RegimeTransition, ...]', final_state: 'RegimeState') -> None", + "RegimeState": "class alphalab.research.regimes.RegimeState(definition_id: 'str', observations: 'int', last_timestamp: 'float | None', regime: 'str | None', candidate: 'str | None', candidate_count: 'int', windows: 'tuple[tuple[str, tuple[float, ...]], ...]') -> None", + "RegimeStatistics": "class alphalab.research.regimes.RegimeStatistics(label: 'str', observations: 'int', share: 'float', spells: 'int', mean_duration: 'float', mean_return: 'float | None', return_std: 'float | None', return_observations: 'int') -> None", + "RegimeTransition": "class alphalab.research.regimes.RegimeTransition(at: 'float', index: 'int', previous: 'str | None', current: 'str') -> None", + "ResearchAdapter": "class alphalab.research.adapter.ResearchAdapter()", + "ResearchCompleted": "class alphalab.research.events.ResearchCompleted(event_id: str, timestamp: float, research_id: str, metric_count: int, finding_count: int) -> None", + "ResearchEngine": "class alphalab.research.engine.ResearchEngine()", + "ResearchError": "class alphalab.research.exceptions.ResearchError", + "ResearchEvent": "class alphalab.research.events.ResearchEvent(event_id: str, timestamp: float) -> None", + "ResearchPayload": "class alphalab.research.protocol.ResearchPayload(strategy_id: str, returns: tuple[float, ...], trades: tuple[alphalab.research.protocol.TradePayload, ...], parameters: collections.abc.Mapping[str, float], market_regimes: tuple[str, ...], aum: float, periods_per_year: int, risk_free_rate: float, sweep: alphalab.research.overfitting.SweepResult | None = None) -> None", + "ResearchPolicy": "class alphalab.research.research.ResearchPolicy(walk_forward_windows: int, ruin_drawdown: float, minimum_trades: int, maximum_trade_share: float, worst_period_return: float, shock_return: float, gain_multiplier: float, loss_multiplier: float) -> None", + "ResearchProtocol": "class alphalab.research.protocol.ResearchProtocol(*args, **kwargs)", + "ResearchStarted": "class alphalab.research.events.ResearchStarted(event_id: str, timestamp: float, research_id: str, strategy_id: str) -> None", + "ResearchState": "class alphalab.research.state.ResearchState(research_id: str, strategy_id: str, timestamp: float, completed: bool = False, bias_report: alphalab.research.bias.BiasReport | None = None, walk_forward_report: alphalab.research.cross_validation.WalkForwardReport | None = None, monte_carlo_report: alphalab.research.montecarlo.MonteCarloReport | None = None, bootstrap_report: alphalab.research.bootstrap.BootstrapReport | None = None, robustness_report: alphalab.research.sensitivity.RobustnessReport | None = None, regime_report: alphalab.research.regime.RegimeReport | None = None, capacity_report: alphalab.research.capacity.CapacityReport | None = None, stress_report: alphalab.research.stress.StressReport | None = None, diagnostic_report: alphalab.research.diagnostics.DiagnosticReport | None = None, policy: alphalab.research.research.ResearchPolicy | None = None, metrics: collections.abc.Mapping[str, float] = , events: tuple[alphalab.research.events.ResearchEvent, ...] = , metadata: collections.abc.Mapping[str, str] = ) -> None", + "ResearchStudy": "class alphalab.research.study.ResearchStudy(study_name: 'str', dataset_version: 'str | None', universe: 'tuple[str, ...]', features: 'tuple[FeatureDefinition, ...]', horizons: 'tuple[int, ...]' = (), splits: 'str' = '', parameters: 'Mapping[str, float]' = , seed: 'int | None' = None, notes: 'str' = '', inputs: 'Mapping[str, str]' = , implementation_lag: 'int | None' = None) -> None", + "ResearchValidationError": "class alphalab.research.exceptions.ResearchValidationError", + "RobustnessReport": "class alphalab.research.sensitivity.RobustnessReport(parameter_count: int, trials: int | None, sensitivity: float | None, neighbour_drop: float | None) -> None", + "STUDY_KEY_SCHEME": "value builtins.str", + "SharpeInference": "class alphalab.research.sharpe_inference.SharpeInference(observations: 'int', sharpe: 'float', skewness: 'float', kurtosis: 'float', benchmark_sharpe: 'float', standard_error: 'float', probabilistic_sharpe: 'float') -> None", + "SignalDiagnostics": "class alphalab.research.signals.SignalDiagnostics(signal_lineage: 'str', dataset_version: 'str | None', horizon: 'int', lag: 'int', label: 'str', observations: 'int', instants: 'int', rank_ic: 'InformationCoefficient', quantiles: 'tuple[QuantileBucket, ...]', buckets_requested: 'int', monotonicity: 'float | None', spread: 'float | None') -> None", + "SplitInterval": "class alphalab.research.splits.SplitInterval(start: 'float', end: 'float') -> None", + "SplitReport": "class alphalab.research.splits.SplitReport(scheme: 'str', folds: 'tuple[TimeSplit, ...]' = , instants: 'int' = 0, label_horizon_seconds: 'float | None' = None, embargo_seconds: 'float' = 0.0) -> None", + "StabilityReport": "class alphalab.research.overfitting.StabilityReport(dimension: 'str', metric: 'str', by_slice: 'Mapping[str, float]', coefficient_of_variation: 'float | None', weakest: 'str', strongest: 'str', positive_share: 'float') -> None", + "StressReport": "class alphalab.research.stress.StressReport(base_drawdown: float | None, shock_return: float, shock_drawdown: float | None, gain_multiplier: float, loss_multiplier: float, liquidity_drawdown: float | None) -> None", + "StudyResult": "class alphalab.research.study.StudyResult(result_id: 'str', study: 'ResearchStudy', metrics: 'Mapping[str, float]' = , splits: 'SplitReport | None' = None, feature_lineage: 'Mapping[str, str]' = , findings: 'tuple[str, ...]' = , warnings: 'tuple[str, ...]' = , produced_at: 'float' = 0.0) -> None", + "SweepResult": "class alphalab.research.overfitting.SweepResult(metric: 'str', scores: 'Mapping[str, float]', best: 'str', best_score: 'float', sensitivity: 'float | None', neighbour_drop: 'float | None', higher_is_better: 'bool' = True) -> None", + "ThresholdRule": "class alphalab.research.regimes.ThresholdRule(signal: 'str', cutoffs: 'tuple[float, ...]', labels: 'tuple[str, ...]') -> None", + "TimeSplit": "class alphalab.research.splits.TimeSplit(index: 'int', train: 'tuple[float, ...]', validation: 'tuple[float, ...]', train_span: 'SplitInterval', validation_span: 'SplitInterval', test: 'tuple[float, ...]' = (), test_span: 'SplitInterval | None' = None, purged: 'tuple[float, ...]' = (), embargoed: 'tuple[float, ...]' = (), validation_purged: 'tuple[float, ...]' = ()) -> None", + "TradePayload": "class alphalab.research.protocol.TradePayload(trade_id: str, symbol: str, entry_price: float, exit_price: float, quantity: float, pnl: float, duration_seconds: float) -> None", + "TrailingQuantileRule": "class alphalab.research.regimes.TrailingQuantileRule(signal: 'str', lookback: 'int', cutoffs: 'tuple[float, ...]', labels: 'tuple[str, ...]') -> None", + "TransitionFrequency": "class alphalab.research.regimes.TransitionFrequency(previous: 'str', current: 'str', count: 'int', probability: 'float') -> None", + "WALK_FORWARD_DESIGN_SCHEME": "value builtins.str", + "WalkForwardDesign": "class alphalab.research.walk_forward_optimization.WalkForwardDesign(space: 'ParameterSpace', refit: 'Refit') -> None", + "WalkForwardObjective": "alias WalkForwardObjective", + "WalkForwardOptimization": "class alphalab.research.walk_forward_optimization.WalkForwardOptimization(design: 'WalkForwardDesign', folds: 'tuple[FoldSelection, ...]', result: 'StudyResult') -> None", + "WalkForwardReport": "class alphalab.research.cross_validation.WalkForwardReport(windows_evaluated: int, window_sharpes: tuple[float, ...], mean_sharpe: float | None, sharpe_variance: float | None, first_to_last_change: float | None) -> None", + "WindowMode": "enum alphalab.research.walk_forward.WindowMode[ROLLING, EXPANDING]", + "analyze_regimes": "function alphalab.research.regime.analyze_regimes(payload: alphalab.research.protocol.ResearchPayload) -> alphalab.research.regime.RegimeReport", + "apply_cost": "function alphalab.research.perturbation.apply_cost(returns: 'Mapping[float, Mapping[str, float]]', cost_per_period: 'float') -> 'dict[float, dict[str, float]]'", + "apply_purge_and_embargo": "function alphalab.research.purging.apply_purge_and_embargo(candidates: 'Sequence[float]', validation: 'SplitInterval', policy: 'PurgePolicy | None') -> 'PurgeResult'", + "apply_stress_tests": "function alphalab.research.stress.apply_stress_tests(payload: alphalab.research.protocol.ResearchPayload, shock_return: float, gain_multiplier: float, loss_multiplier: float) -> alphalab.research.stress.StressReport", + "bias_report": "function alphalab.research.views.bias_report(state: alphalab.research.state.ResearchState) -> alphalab.research.bias.BiasReport | None", + "block_bootstrap_indices": "function alphalab.research.perturbation.block_bootstrap_indices(count: 'int', block_size: 'int', seed: 'int', blocks: 'int | None' = None) -> 'tuple[int, ...]'", + "bootstrap_statistics": "function alphalab.research.bootstrap.bootstrap_statistics(payload: alphalab.research.protocol.ResearchPayload, seed: int, iterations: int = 1000) -> alphalab.research.bootstrap.BootstrapReport", + "build_overfitting_report": "function alphalab.research.overfitting.build_overfitting_report(metric: 'str', in_sample: 'float', out_of_sample: 'float', policy: 'OverfittingPolicy', effective_parameters: 'int', sweep: 'SweepResult | None' = None, period: 'StabilityReport | None' = None, symbol: 'StabilityReport | None' = None) -> 'OverfittingReport'", + "build_result": "function alphalab.research.study.build_result(study: 'ResearchStudy', metrics: 'Mapping[str, float]', produced_at: 'float', splits: 'SplitReport | None' = None, feature_lineage: 'Mapping[str, str] | None' = None, findings: 'Sequence[str]' = (), warnings: 'Sequence[str]' = ()) -> 'StudyResult'", + "calculate_cagr": "function alphalab.research.metrics.calculate_cagr(returns: collections.abc.Sequence[float], periods_per_year: int) -> float", + "calculate_max_drawdown": "function alphalab.common.statistics.compounded_max_drawdown(returns: 'Sequence[float]') -> 'float'", + "calculate_sharpe": "function alphalab.research.metrics.calculate_sharpe(returns: collections.abc.Sequence[float], periods_per_year: int, risk_free_rate: float) -> float", + "calculate_volatility": "function alphalab.research.metrics.calculate_volatility(returns: collections.abc.Sequence[float], periods_per_year: int) -> float", + "canonical_event_study_key": "function alphalab.research.event_study.canonical_event_study_key(definition: 'EventStudyDefinition') -> 'str'", + "canonical_regime_key": "function alphalab.research.regimes.canonical_regime_key(definition: 'RegimeDefinition') -> 'str'", + "canonical_result_key": "function alphalab.research.study.canonical_result_key(study: 'ResearchStudy', metrics: 'Mapping[str, float]') -> 'str'", + "canonical_study_key": "function alphalab.research.study.canonical_study_key(study: 'ResearchStudy') -> 'str'", + "capacity_report": "function alphalab.research.views.capacity_report(state: alphalab.research.state.ResearchState) -> alphalab.research.capacity.CapacityReport | None", + "classify_regimes": "function alphalab.research.regimes.classify_regimes(definition: 'RegimeDefinition', signals: 'Mapping[str, Sequence[tuple[float, float]]]', *, input_id: 'str | None', initial: 'RegimeState | None' = None) -> 'RegimeSeries'", + "conditional_diagnostics": "function alphalab.research.signals.conditional_diagnostics(signal: 'FeaturePanel', returns: 'ForwardReturnPanel', regimes: 'Mapping[float, str]', buckets: 'int' = 5, minimum_assets: 'int' = 5, minimum_instants: 'int' = 2) -> 'dict[str, SignalDiagnostics]'", + "correct_p_values": "function alphalab.research.multiple_testing.correct_p_values(p_values: 'Mapping[str, float]', correction: 'Correction', alpha: 'float') -> 'MultipleTestingResult'", + "cross_validation_splits": "function alphalab.research.time_series_cv.cross_validation_splits(timestamps: 'Sequence[float]', folds: 'int', method: 'CVMethod', policy: 'PurgePolicy | None' = None) -> 'SplitReport'", + "deflated_sharpe_ratio": "function alphalab.research.sharpe_inference.deflated_sharpe_ratio(returns: 'Sequence[float]', trial_sharpes: 'Sequence[float]', *, independent_trials: 'int') -> 'DeflatedSharpe'", + "delay_signal": "function alphalab.research.perturbation.delay_signal(panel: 'FeaturePanel', periods: 'int') -> 'FeaturePanel'", + "derive_result_id": "function alphalab.research.study.derive_result_id(study: 'ResearchStudy', metrics: 'Mapping[str, float]') -> 'str'", + "derive_study_id": "function alphalab.research.study.derive_study_id(study: 'ResearchStudy') -> 'str'", + "detect_bias": "function alphalab.research.bias.detect_bias(payload: alphalab.research.protocol.ResearchPayload) -> alphalab.research.bias.BiasReport", + "diagnostic_report": "function alphalab.research.views.diagnostic_report(state: alphalab.research.state.ResearchState) -> alphalab.research.diagnostics.DiagnosticReport | None", + "drop_observations": "function alphalab.research.perturbation.drop_observations(frame: 'ObservationFrame', fraction: 'float', seed: 'int') -> 'ObservationFrame'", + "estimate_capacity": "function alphalab.research.capacity.estimate_capacity(payload: alphalab.research.protocol.ResearchPayload) -> alphalab.research.capacity.CapacityReport", + "event_study": "function alphalab.research.event_study.event_study(events: 'ObservationSet[InformationEvent]', prices: 'ObservationFrame', calendar: 'SessionCalendar', definition: 'EventStudyDefinition') -> 'EventStudyResult'", + "event_study_metrics": "function alphalab.research.event_study.event_study_metrics(result: 'EventStudyResult') -> 'dict[str, float]'", + "expected_maximum_sharpe": "function alphalab.research.sharpe_inference.expected_maximum_sharpe(independent_trials: 'int', trial_sharpe_variance: 'float') -> 'float'", + "generate_diagnostics": "function alphalab.research.diagnostics.generate_diagnostics(payload: alphalab.research.protocol.ResearchPayload, minimum_trades: int, maximum_trade_share: float, worst_period_return: float) -> alphalab.research.diagnostics.DiagnosticReport", + "initial_regime_state": "function alphalab.research.regimes.initial_regime_state(definition: 'RegimeDefinition') -> 'RegimeState'", + "label_ends_from_horizon": "function alphalab.research.purging.label_ends_from_horizon(timestamps: 'Sequence[float]', horizon_periods: 'int') -> 'dict[float, float]'", + "monte_carlo_orders": "function alphalab.research.perturbation.monte_carlo_orders(count: 'int', paths: 'int', seed: 'int') -> 'tuple[tuple[int, ...], ...]'", + "monte_carlo_simulation": "function alphalab.research.montecarlo.monte_carlo_simulation(payload: alphalab.research.protocol.ResearchPayload, seed: int, ruin_drawdown: float, simulations: int = 1000) -> alphalab.research.montecarlo.MonteCarloReport", + "parameter_robustness": "function alphalab.research.sensitivity.parameter_robustness(payload: alphalab.research.protocol.ResearchPayload) -> alphalab.research.sensitivity.RobustnessReport", + "parameter_sweep": "function alphalab.research.overfitting.parameter_sweep(metric: 'str', configurations: 'Sequence[str]', evaluate: 'Callable[[str], float]', *, higher_is_better: 'bool' = True) -> 'SweepResult'", + "per_period_sharpe": "function alphalab.research.sharpe_inference.per_period_sharpe(returns: 'Sequence[float]') -> 'float'", + "period_stability": "function alphalab.research.overfitting.period_stability(metric: 'str', by_period: 'Mapping[str, float]') -> 'StabilityReport'", + "perturb_observations": "function alphalab.research.perturbation.perturb_observations(frame: 'ObservationFrame', relative_sigma: 'float', seed: 'int') -> 'ObservationFrame'", + "perturb_signal": "function alphalab.research.perturbation.perturb_signal(panel: 'FeaturePanel', absolute_sigma: 'float', seed: 'int') -> 'FeaturePanel'", + "probabilistic_sharpe_ratio": "function alphalab.research.sharpe_inference.probabilistic_sharpe_ratio(returns: 'Sequence[float]', benchmark_sharpe: 'float') -> 'SharpeInference'", + "regime_profile": "function alphalab.research.regimes.regime_profile(series: 'RegimeSeries', returns: 'Mapping[float, float] | None') -> 'RegimeProfile'", + "regime_series_from_features": "function alphalab.research.regimes.regime_series_from_features(definition: 'RegimeDefinition', features: 'Mapping[str, FeatureSeries]', *, initial: 'RegimeState | None' = None) -> 'RegimeSeries'", + "require_chronological": "function alphalab.research.splits.require_chronological(timestamps: 'Sequence[float]') -> 'tuple[float, ...]'", + "research_metrics": "function alphalab.research.research.research_metrics(payload: alphalab.research.protocol.ResearchPayload, bias: alphalab.research.bias.BiasReport, walk_forward: alphalab.research.cross_validation.WalkForwardReport, monte_carlo: alphalab.research.montecarlo.MonteCarloReport, bootstrap: alphalab.research.bootstrap.BootstrapReport, robustness: alphalab.research.sensitivity.RobustnessReport, regime: alphalab.research.regime.RegimeReport, capacity: alphalab.research.capacity.CapacityReport, stress: alphalab.research.stress.StressReport) -> collections.abc.Mapping[str, float]", + "research_metrics_of": "function alphalab.research.views.research_metrics_of(state: alphalab.research.state.ResearchState) -> collections.abc.Mapping[str, float]", + "sample_by": "function alphalab.research.perturbation.sample_by(values: 'Sequence[float]', positions: 'Sequence[int]') -> 'tuple[float, ...]'", + "sample_degradation": "function alphalab.research.overfitting.sample_degradation(in_sample: 'float', out_of_sample: 'float') -> 'float | None'", + "shift_parameter": "function alphalab.research.perturbation.shift_parameter(window: 'int', relative: 'float') -> 'int'", + "signal_diagnostics": "function alphalab.research.signals.signal_diagnostics(signal: 'FeaturePanel', returns: 'ForwardReturnPanel', buckets: 'int' = 5, minimum_assets: 'int' = 5, tie_break: 'TieBreak' = , label: 'str' = '', instants: 'Sequence[float] | None' = None) -> 'SignalDiagnostics'", + "signal_horizons": "function alphalab.research.signals.signal_horizons(signal: 'FeaturePanel', prices: 'ObservationFrame', horizons: 'Sequence[int]', buckets: 'int' = 5, minimum_assets: 'int' = 5, *, lag: 'int', delistings: 'Sequence[DelistingReturn]') -> 'dict[int, SignalDiagnostics]'", + "stress_report": "function alphalab.research.views.stress_report(state: alphalab.research.state.ResearchState) -> alphalab.research.stress.StressReport | None", + "symbol_stability": "function alphalab.research.overfitting.symbol_stability(metric: 'str', by_symbol: 'Mapping[str, float]') -> 'StabilityReport'", + "validate_payload": "function alphalab.research.validation.validate_payload(payload: alphalab.research.protocol.ResearchPayload) -> None", + "walk_forward_analysis": "function alphalab.research.cross_validation.walk_forward_analysis(payload: alphalab.research.protocol.ResearchPayload, num_windows: int) -> alphalab.research.cross_validation.WalkForwardReport", + "walk_forward_optimize": "function alphalab.research.walk_forward_optimization.walk_forward_optimize(study: 'ResearchStudy', design: 'WalkForwardDesign', objective: 'WalkForwardObjective', splits: 'SplitReport', *, produced_at: 'float') -> 'WalkForwardOptimization'", + "walk_forward_splits": "function alphalab.research.walk_forward.walk_forward_splits(timestamps: 'Sequence[float]', train_size: 'int', validation_size: 'int', test_size: 'int', mode: 'WindowMode', step: 'int | None' = None, policy: 'PurgePolicy | None' = None) -> 'SplitReport'", + "warnings": "function alphalab.research.views.warnings(state: alphalab.research.state.ResearchState) -> collections.abc.Sequence[str]" + }, + "alphalab.research_assistant": { + "AssistantReport": "class alphalab.research_assistant.report.AssistantReport(title: str, template: str, objective: str, higher_is_better: bool, candidate_count: int, best_candidate_id: str, best_score: float, ranked_rows: tuple[tuple[str, float], ...] = , generated_at: float = 0.0) -> None", + "CandidateEvaluation": "class alphalab.research_assistant.evaluation.CandidateEvaluation(candidate: alphalab.research_assistant.generation.StrategyCandidate, metrics: collections.abc.Mapping[str, float], score: float) -> None", + "Evaluator": "alias collections.abc.Callable[[alphalab.research_assistant.generation.StrategyCandidate], collections.abc.Mapping[str, float]]", + "ParameterAxes": "alias ParameterAxes", + "ParameterSpace": "class alphalab.research.walk_forward_optimization.ParameterSpace(candidates: 'tuple[Mapping[str, ParamValue], ...]') -> None", + "ResearchAssistantError": "class alphalab.research_assistant.exceptions.ResearchAssistantError", + "ResearchAssistantInputError": "class alphalab.research_assistant.exceptions.ResearchAssistantInputError", + "ResearchWorkflowResult": "class alphalab.research_assistant.workflow.ResearchWorkflowResult(template: str, objective: str, candidates: tuple[alphalab.research_assistant.generation.StrategyCandidate, ...], evaluations: tuple[alphalab.research_assistant.evaluation.CandidateEvaluation, ...], best: alphalab.research_assistant.evaluation.CandidateEvaluation, report: alphalab.research_assistant.report.AssistantReport, sweep: alphalab.research.overfitting.SweepResult, tracker: alphalab.experiment_tracking.tracker.ExperimentTracker | None = None) -> None", + "StrategyCandidate": "class alphalab.research_assistant.generation.StrategyCandidate(candidate_id: str, template: str, parameters: collections.abc.Mapping[str, ParamValue] = ) -> None", + "best_evaluation": "function alphalab.research_assistant.evaluation.best_evaluation(evaluations: tuple[alphalab.research_assistant.evaluation.CandidateEvaluation, ...], higher_is_better: bool = True) -> alphalab.research_assistant.evaluation.CandidateEvaluation", + "build_report": "function alphalab.research_assistant.report.build_report(template: str, objective: str, evaluations: tuple[alphalab.research_assistant.evaluation.CandidateEvaluation, ...], timestamp: float, higher_is_better: bool = True, title: str | None = None) -> alphalab.research_assistant.report.AssistantReport", + "candidate_count": "function alphalab.research_assistant.generation.candidate_count(space: alphalab.research.walk_forward_optimization.ParameterSpace | ParameterAxes) -> int", + "evaluate_candidates": "function alphalab.research_assistant.evaluation.evaluate_candidates(candidates: tuple[alphalab.research_assistant.generation.StrategyCandidate, ...], evaluator: collections.abc.Callable[[alphalab.research_assistant.generation.StrategyCandidate], collections.abc.Mapping[str, float]], objective: str, higher_is_better: bool = True) -> tuple[alphalab.research_assistant.evaluation.CandidateEvaluation, ...]", + "generate_candidates": "function alphalab.research_assistant.generation.generate_candidates(template: str, space: alphalab.research.walk_forward_optimization.ParameterSpace | ParameterAxes, limit: int | None = None) -> tuple[alphalab.research_assistant.generation.StrategyCandidate, ...]", + "parameter_space": "function alphalab.research_assistant.generation.parameter_space(space: alphalab.research.walk_forward_optimization.ParameterSpace | ParameterAxes) -> alphalab.research.walk_forward_optimization.ParameterSpace", + "rank_evaluations": "function alphalab.research_assistant.evaluation.rank_evaluations(evaluations: tuple[alphalab.research_assistant.evaluation.CandidateEvaluation, ...], higher_is_better: bool = True) -> tuple[alphalab.research_assistant.evaluation.CandidateEvaluation, ...]", + "render_markdown": "function alphalab.research_assistant.report.render_markdown(report: alphalab.research_assistant.report.AssistantReport) -> str", + "run_research_workflow": "function alphalab.research_assistant.workflow.run_research_workflow(template: str, space: alphalab.research.walk_forward_optimization.ParameterSpace | ParameterAxes, evaluator: collections.abc.Callable[[alphalab.research_assistant.generation.StrategyCandidate], collections.abc.Mapping[str, float]], objective: str, timestamp: float, higher_is_better: bool = True, limit: int | None = None, tracker: alphalab.experiment_tracking.tracker.ExperimentTracker | None = None) -> alphalab.research_assistant.workflow.ResearchWorkflowResult", + "to_strategy_definition": "function alphalab.research_assistant.definition.to_strategy_definition(candidate: alphalab.research_assistant.generation.StrategyCandidate, name: str, version: str, author: str, description: str, metadata: collections.abc.Mapping[str, str] | None = None) -> alphalab.strategy.definition.StrategyDefinition", + "top_k": "function alphalab.research_assistant.evaluation.top_k(evaluations: tuple[alphalab.research_assistant.evaluation.CandidateEvaluation, ...], k: int, higher_is_better: bool = True) -> tuple[alphalab.research_assistant.evaluation.CandidateEvaluation, ...]" + }, + "alphalab.risk": { + "BucketExposure": "class alphalab.risk.projection.BucketExposure(dimension: 'str', label: 'str | None', committed_gross: 'Decimal', projected_gross: 'Decimal') -> None", + "BuyingPowerUpdated": "class alphalab.risk.events.BuyingPowerUpdated(event_id: str, timestamp: float, buying_power: decimal.Decimal) -> None", + "ClassificationLimit": "class alphalab.risk.limits.ClassificationLimit(dimension: str, max_gross: decimal.Decimal | None = None, max_share: decimal.Decimal | None = None, label: str | None = None, refuse_unclassified: bool = False) -> None", + "DailyLossLimit": "class alphalab.risk.limits.DailyLossLimit(max_daily_loss: decimal.Decimal, zone: str, day_start: datetime.time = datetime.time(0, 0)) -> None", + "DrawdownLimit": "class alphalab.risk.limits.DrawdownLimit(max_drawdown_pct: decimal.Decimal) -> None", + "DrawdownTriggered": "class alphalab.risk.events.DrawdownTriggered(event_id: str, timestamp: float, current_drawdown: decimal.Decimal, max_drawdown: decimal.Decimal) -> None", + "ExposureLimit": "class alphalab.risk.limits.ExposureLimit(max_gross_exposure: decimal.Decimal, max_net_exposure: decimal.Decimal) -> None", + "ExposureStatus": "class alphalab.risk.exposure.ExposureStatus(gross_exposure: decimal.Decimal = Decimal('0.00'), net_exposure: decimal.Decimal = Decimal('0.00'), long_exposure: decimal.Decimal = Decimal('0.00'), short_exposure: decimal.Decimal = Decimal('0.00'), asset_exposure: collections.abc.Mapping[str, decimal.Decimal] = , sector_exposure: collections.abc.Mapping[str, decimal.Decimal] = ) -> None", + "ExposureUpdated": "class alphalab.risk.events.ExposureUpdated(event_id: str, timestamp: float, gross_exposure: decimal.Decimal, net_exposure: decimal.Decimal) -> None", + "LeverageLimit": "class alphalab.risk.limits.LeverageLimit(max_leverage: decimal.Decimal) -> None", + "MarginLimit": "class alphalab.risk.limits.MarginLimit(max_margin_utilization: decimal.Decimal) -> None", + "MarginStatus": "class alphalab.risk.margin.MarginStatus(initial_margin: decimal.Decimal = Decimal('0.00'), maintenance_margin: decimal.Decimal = Decimal('0.00'), available_margin: decimal.Decimal = Decimal('0.00'), margin_used: decimal.Decimal = Decimal('0.00')) -> None", + "MarginUpdated": "class alphalab.risk.events.MarginUpdated(event_id: str, timestamp: float, margin_used: decimal.Decimal, available_margin: decimal.Decimal) -> None", + "NO_WORKING_ORDERS": "value builtins.mappingproxy", + "OrderRequest": "class alphalab.core.order_request.OrderRequest(order_id: str, strategy_id: str, asset_id: str, side: alphalab.core.enums.Side, quantity: decimal.Decimal, price: decimal.Decimal, timestamp: float = 0.0, contributions: tuple[alphalab.core.contribution.StrategyContribution, ...] = , terms: alphalab.common.order_terms.OrderTerms = OrderTerms(order_type=, limit_price=None, stop_price=None, time_in_force=, expire_at=None)) -> None", + "OrderSizeLimit": "class alphalab.risk.limits.OrderSizeLimit(max_quantity: decimal.Decimal, max_notional: decimal.Decimal) -> None", + "PositionLimit": "class alphalab.risk.limits.PositionLimit(max_quantity: decimal.Decimal, max_notional: decimal.Decimal) -> None", + "RiskApproved": "class alphalab.risk.events.RiskApproved(event_id: str, timestamp: float, decision_id: str, order_id: str) -> None", + "RiskCheckStarted": "class alphalab.risk.events.RiskCheckStarted(event_id: str, timestamp: float, request: alphalab.core.order_request.OrderRequest) -> None", + "RiskConfigurationError": "class alphalab.risk.exceptions.RiskConfigurationError", + "RiskDecision": "class alphalab.risk.decision.RiskDecision(decision_id: str, timestamp: float, order_id: str, approved: bool, reason: str, violations: tuple[alphalab.risk.models.RiskViolation, ...] = , required_margin: decimal.Decimal = Decimal('0.00'), remaining_buying_power: decimal.Decimal = Decimal('0.00'), exposure: alphalab.risk.exposure.ExposureStatus = , breaches: tuple[alphalab.risk.models.RiskViolation, ...] = ) -> None", + "RiskEngine": "class alphalab.risk.engine.RiskEngine()", + "RiskError": "class alphalab.risk.exceptions.RiskError", + "RiskEvent": "class alphalab.risk.events.RiskEvent(event_id: str, timestamp: float) -> None", + "RiskLimits": "class alphalab.risk.limits.RiskLimits(order_size: alphalab.risk.limits.OrderSizeLimit, position: alphalab.risk.limits.PositionLimit, exposure: alphalab.risk.limits.ExposureLimit, leverage: alphalab.risk.limits.LeverageLimit, margin: alphalab.risk.limits.MarginLimit, daily_loss: alphalab.risk.limits.DailyLossLimit | None, drawdown: alphalab.risk.limits.DrawdownLimit, classification: tuple[alphalab.risk.limits.ClassificationLimit, ...] = ()) -> None", + "RiskProjection": "class alphalab.risk.projection.RiskProjection(asset_id: 'str', order_quantity: 'Decimal', price: 'Decimal', committed_position: 'Decimal', projected_position: 'Decimal', committed_gross: 'Decimal', projected_gross: 'Decimal', committed_net: 'Decimal', projected_net: 'Decimal', increasing_notional: 'Decimal', available_buying_power: 'Decimal', nav: 'Decimal') -> None", + "RiskRejected": "class alphalab.risk.events.RiskRejected(event_id: str, timestamp: float, decision_id: str, order_id: str, reason: str) -> None", + "RiskSeverity": "enum alphalab.risk.models.RiskSeverity[HIGH, CRITICAL]", + "RiskState": "class alphalab.risk.state.RiskState(active_limits: alphalab.risk.limits.RiskLimits, margin: alphalab.risk.margin.MarginStatus = , exposure: alphalab.risk.exposure.ExposureStatus = , cash: decimal.Decimal = Decimal('0.00'), buying_power: decimal.Decimal = Decimal('0.00'), peak_nav: decimal.Decimal = Decimal('0.00'), current_nav: decimal.Decimal = Decimal('0.00'), daily_loss: decimal.Decimal = Decimal('0.00'), history: alphalab.common.append_log.AppendOnlyLog[alphalab.risk.decision.RiskDecision] = , events: alphalab.common.append_log.AppendOnlyLog[alphalab.risk.events.RiskEvent] = , trading_day: str | None = None, day_start_nav: decimal.Decimal | None = None) -> None", + "RiskValidationError": "class alphalab.risk.exceptions.RiskValidationError", + "RiskViolation": "class alphalab.risk.models.RiskViolation(rule: str, description: str, severity: alphalab.risk.models.RiskSeverity, current_value: decimal.Decimal, allowed_value: decimal.Decimal) -> None", + "WorkingExposure": "class alphalab.risk.projection.WorkingExposure(quantity: 'Decimal', price: 'Decimal', position: 'Decimal') -> None", + "active_limits": "function alphalab.risk.views.active_limits(state: alphalab.risk.state.RiskState) -> alphalab.risk.limits.RiskLimits", + "check_buying_power": "function alphalab.risk.checks.check_buying_power(request: alphalab.core.order_request.OrderRequest, state: alphalab.risk.state.RiskState, projection: alphalab.risk.projection.RiskProjection) -> alphalab.risk.models.RiskViolation | None", + "check_classification": "function alphalab.risk.checks.check_classification(state: alphalab.risk.state.RiskState, projection: alphalab.risk.projection.RiskProjection, buckets: tuple[alphalab.risk.projection.BucketExposure, ...]) -> alphalab.risk.models.RiskViolation | None", + "check_daily_loss": "function alphalab.risk.checks.check_daily_loss(state: alphalab.risk.state.RiskState, projection: alphalab.risk.projection.RiskProjection) -> alphalab.risk.models.RiskViolation | None", + "check_drawdown": "function alphalab.risk.checks.check_drawdown(state: alphalab.risk.state.RiskState, projection: alphalab.risk.projection.RiskProjection) -> alphalab.risk.models.RiskViolation | None", + "check_exposure": "function alphalab.risk.checks.check_exposure(request: alphalab.core.order_request.OrderRequest, state: alphalab.risk.state.RiskState, projection: alphalab.risk.projection.RiskProjection) -> alphalab.risk.models.RiskViolation | None", + "check_leverage": "function alphalab.risk.checks.check_leverage(request: alphalab.core.order_request.OrderRequest, state: alphalab.risk.state.RiskState, projection: alphalab.risk.projection.RiskProjection) -> alphalab.risk.models.RiskViolation | None", + "check_margin": "function alphalab.risk.checks.check_margin(request: alphalab.core.order_request.OrderRequest, state: alphalab.risk.state.RiskState, projection: alphalab.risk.projection.RiskProjection) -> alphalab.risk.models.RiskViolation | None", + "check_net_exposure": "function alphalab.risk.checks.check_net_exposure(request: alphalab.core.order_request.OrderRequest, state: alphalab.risk.state.RiskState, projection: alphalab.risk.projection.RiskProjection) -> alphalab.risk.models.RiskViolation | None", + "check_order_size": "function alphalab.risk.checks.check_order_size(request: alphalab.core.order_request.OrderRequest, state: alphalab.risk.state.RiskState, projection: alphalab.risk.projection.RiskProjection) -> alphalab.risk.models.RiskViolation | None", + "check_position_limit": "function alphalab.risk.checks.check_position_limit(request: alphalab.core.order_request.OrderRequest, state: alphalab.risk.state.RiskState, projection: alphalab.risk.projection.RiskProjection) -> alphalab.risk.models.RiskViolation | None", + "current_exposure": "function alphalab.risk.views.current_exposure(state: alphalab.risk.state.RiskState) -> alphalab.risk.exposure.ExposureStatus", + "daily_loss_breach": "function alphalab.risk.checks.daily_loss_breach(state: alphalab.risk.state.RiskState) -> alphalab.risk.models.RiskViolation | None", + "drawdown_breach": "function alphalab.risk.checks.drawdown_breach(state: alphalab.risk.state.RiskState) -> alphalab.risk.models.RiskViolation | None", + "latest_decision": "function alphalab.risk.views.latest_decision(state: alphalab.risk.state.RiskState) -> alphalab.risk.decision.RiskDecision | None", + "margin_status": "function alphalab.risk.views.margin_status(state: alphalab.risk.state.RiskState) -> alphalab.risk.margin.MarginStatus", + "project": "function alphalab.risk.projection.project(state: 'RiskState', request: 'OrderRequest', *, position: 'Decimal', price: 'Decimal', working: 'Mapping[str, WorkingExposure]' = mappingproxy({})) -> 'RiskProjection'", + "risk_history": "function alphalab.risk.views.risk_history(state: alphalab.risk.state.RiskState) -> collections.abc.Sequence[alphalab.risk.decision.RiskDecision]", + "validate_order_request": "function alphalab.risk.validation.validate_order_request(request: alphalab.core.order_request.OrderRequest) -> None", + "violations": "function alphalab.risk.views.violations(state: alphalab.risk.state.RiskState) -> collections.abc.Sequence[alphalab.risk.models.RiskViolation]" + }, + "alphalab.runtime": { + "AlphaLabRuntimeError": "class alphalab.runtime.exceptions.AlphaLabRuntimeError", + "CHECKPOINT_SCHEMA": "value builtins.int", + "CheckpointMark": "class alphalab.runtime.checkpoint.CheckpointMark(sequence: 'int', digest: 'str', ends: 'Mapping[str, int]', entries: 'Mapping[str, tuple[tuple[Any, Any], ...]]' = ) -> None", + "ChildOrderBindings": "class alphalab.runtime.broker_routing.ChildOrderBindings(to_parent: 'PersistentMap[str, str]' = , by_parent: 'PersistentMap[str, tuple[str, ...]]' = ) -> None", + "ChildRoutingResult": "class alphalab.runtime.broker_routing.ChildRoutingResult(broker_state: 'BrokerState', children: 'ChildOrderBindings', order: 'BrokerOrder | None', decision: 'RoutingDecision', events: 'tuple[BrokerEvent, ...]' = ()) -> None", + "ExecutionAssumptions": "class alphalab.runtime.assumptions.ExecutionAssumptions(routing: 'ExecutionRouting', fill_timing: 'FillTiming', fill_policy: 'str', costs: 'str', latency: 'str', frictionless: 'bool', unlimited_liquidity: 'bool', queue_model: 'str | None' = None) -> None", + "ExecutionMode": "enum alphalab.runtime.run.ExecutionMode[BACKTEST, REPLAY, PAPER, LIVE]", + "ExecutionPipeline": "class alphalab.runtime.execution_pipeline.ExecutionPipeline()", + "ExecutionPipelineConfig": "class alphalab.runtime.execution_pipeline.ExecutionPipelineConfig(account: 'Account', starting_cash: 'Decimal', budget: 'CapitalBudget', allocation_constraints: 'AllocationConstraints', risk_limits: 'RiskLimits', sizing_model: 'SizingModel' = , simulator: 'ExecutionSimulator' = , venue: 'str' = 'SIM', currency: 'str' = '', also_settles: 'frozenset[str]' = frozenset(), routing: 'ExecutionRouting' = , instruments: 'InstrumentRegistry | None' = None, fill_timing: 'FillTiming' = , calendars: 'VenueCalendars' = , retention: 'RetentionPolicy' = ) -> None", + "ExecutionPipelineResult": "class alphalab.runtime.execution_pipeline.ExecutionPipelineResult(state: 'ExecutionPipelineState', market_event: 'MarketEvent', intents: 'tuple[Intent, ...]', order_requests: 'tuple[OrderRequest, ...]', risk_decisions: 'tuple[RiskDecision, ...]', oms_orders: 'tuple[OMSOrder, ...]', execution_reports: 'tuple[ExecutionReport, ...]', fills: 'tuple[CoreFill, ...]', trades: 'tuple[CoreTrade, ...]', unpriced_requests: 'tuple[OrderRequest, ...]' = , valuation: 'PortfolioValuationSnapshot | None' = None, settlement_refusals: 'tuple[SettlementRefusal, ...]' = , queue: 'QueueBook | None' = None) -> None", + "ExecutionPipelineState": "class alphalab.runtime.execution_pipeline.ExecutionPipelineState(config: 'ExecutionPipelineConfig', market: 'MarketState', strategy: 'StrategyRuntimeState', allocation: 'AllocationState', risk: 'RiskState', oms: 'OMSState', execution: 'ExecutionState', portfolio: 'PortfolioState', analytics: 'AnalyticsState', market_prices: 'Mapping[str, Decimal]' = , fills: 'AppendOnlyLog[CoreFill]' = , trades: 'AppendOnlyLog[CoreTrade]' = , trade_records: 'AppendOnlyLog[TradeRecord]' = , portfolio_snapshots: 'AppendOnlyLog[PortfolioSnapshot]' = , unpriced_assets: 'PersistentMap[str, UnpricedAsset]' = , id_position: 'IdStreamPosition' = ) -> None", + "ExecutionRouting": "enum alphalab.runtime.execution_pipeline.ExecutionRouting[SIMULATED, EXTERNAL, ALGORITHMIC]", + "HistoryNotRetainedError": "class alphalab.runtime.exceptions.HistoryNotRetainedError", + "LiveRunState": "class alphalab.runtime.live.LiveRunState(run: 'RunState', broker: 'BrokerState', routing: 'RoutingConfig', mapping: 'ExternalOrderMap' = , reconciliation: 'ReconciliationLog' = , routed: 'AppendOnlyLog[RoutedOrder]' = , settled: 'AppendOnlyLog[SettledExecution]' = , children: 'ChildOrderBindings' = , requests: 'RequestLedger' = , held: 'frozenset[str]' = frozenset()) -> None", + "LiveSession": "class alphalab.runtime.live.LiveSession()", + "LiveStep": "class alphalab.runtime.live.LiveStep(settled: 'tuple[SettledExecution, ...]' = (), routed: 'tuple[RoutedOrder, ...]' = (), result: 'ExecutionPipelineResult | None' = None, events: 'tuple[BrokerEvent, ...]' = ()) -> None", + "RetentionPolicy": "class alphalab.runtime.retention.RetentionPolicy(market_history: 'int | None' = None, steps: 'int | None' = None, audit_events: 'int | None' = None, results: 'int | None' = None) -> None", + "RoutedOrder": "class alphalab.runtime.live.RoutedOrder(oms_order_id: 'str', broker_order_id: 'str | None', decision: 'RoutingDecision') -> None", + "RoutingConfig": "class alphalab.runtime.broker_routing.RoutingConfig(venue: 'str', currency: 'str', order_type: 'OrderType' = ) -> None", + "RoutingDecision": "class alphalab.runtime.broker_routing.RoutingDecision(routed: 'bool', refusal: 'RoutingRefusal | None' = None, reason: 'str' = '') -> None", + "RoutingRefusal": "enum alphalab.runtime.broker_routing.RoutingRefusal[DISCONNECTED, DUPLICATE_SUBMISSION, CAPABILITY_MISMATCH, INVALID_CHILD, PARENT_ROUTED_DIRECTLY]", + "RoutingResult": "class alphalab.runtime.broker_routing.RoutingResult(broker_state: 'BrokerState', mapping: 'ExternalOrderMap', order: 'BrokerOrder | None', decision: 'RoutingDecision', events: 'tuple[BrokerEvent, ...]' = ()) -> None", + "RunConfig": "class alphalab.runtime.run.RunConfig(pipeline: 'ExecutionPipelineConfig', mode: 'ExecutionMode', fill_policy: 'FillPolicy' = , seed: 'int | None' = None, start_timestamp: 'float' = 0.0, ordering: 'OrderingGuarantee' = , max_market_data_age_seconds: 'float | None' = None, years_elapsed: 'float | None' = None, risk_free_rate: 'float' = 0.0, compile_analytics: 'bool' = True, periods_per_year: 'float | None' = None, halt_on_strategy_failure: 'bool' = False) -> None", + "RunEngine": "class alphalab.runtime.run.RunEngine()", + "RunState": "class alphalab.runtime.run.RunState(config: 'RunConfig', pipeline: 'ExecutionPipelineState', processed: 'int' = 0, current_timestamp: 'float' = 0.0, last_record_timestamp: 'float | None' = None, source_id: 'str | None' = None, steps: 'AppendOnlyLog[RunStep]' = , skipped: 'AppendOnlyLog[SkippedRecord]' = , last_slice_at: 'float | None' = None, observations_delivered: 'int' = 0, last_observation: 'tuple[float, str] | None' = None) -> None", + "RunStep": "class alphalab.runtime.run.RunStep(index: 'int', event_id: 'str', timestamp: 'float', orders: 'tuple[OMSOrder, ...]', reports: 'tuple[ExecutionReport, ...]', fills: 'tuple[CoreFill, ...]', equity: 'Decimal') -> None", + "RuntimeValidationError": "class alphalab.runtime.exceptions.RuntimeValidationError", + "SettledExecution": "class alphalab.runtime.live.SettledExecution(execution: 'BrokerExecution', decision: 'ExecutionDecision', fills: 'tuple[CoreFill, ...]' = ()) -> None", + "SettlementRefusal": "class alphalab.runtime.execution_pipeline.SettlementRefusal(asset_id: 'str', instrument_currency: 'str', settlement_currency: 'str', detail: 'str', timestamp: 'float') -> None", + "SkippedRecord": "class alphalab.runtime.run.SkippedRecord(record: 'MarketRecord', reason: 'str') -> None", + "StrategyFailedError": "class alphalab.runtime.run.StrategyFailedError(message: 'str', state: 'RunState', failures: 'tuple[StrategyFailure, ...]') -> 'None'", + "StrategyFailure": "class alphalab.runtime.run.StrategyFailure(strategy_id: 'str', timestamp: 'float', error: 'str') -> None", + "TradingSession": "class alphalab.runtime.session.TradingSession()", + "UnpricedAsset": "class alphalab.runtime.execution_pipeline.UnpricedAsset(asset_id: 'str', reason: 'UnpricedReason', detail: 'str', first_timestamp: 'float', last_timestamp: 'float', occurrences: 'int') -> None", + "UnpricedReason": "enum alphalab.runtime.execution_pipeline.UnpricedReason[NO_PRICE_OBSERVED, NOT_REGISTERED, REGISTERED_BUT_UNPRICED]", + "VenueCalendars": "class alphalab.runtime.calendars.VenueCalendars(by_exchange: 'Mapping[str, MarketCalendar]' = , default: 'MarketCalendar | None' = None) -> None", + "apply_broker_execution": "function alphalab.runtime.broker_routing.apply_broker_execution(state: 'ExecutionPipelineState', oms_order: 'OMSOrder', execution: 'BrokerExecution', config: 'RoutingConfig', rates: 'FxRates' = FxRates(rates={}, max_age_seconds=None, currency_units=CurrencyUnits({}))) -> 'tuple[ExecutionPipelineState, tuple[CoreFill, ...], tuple[CoreTrade, ...]]'", + "checkpoint": "function alphalab.runtime.checkpoint.checkpoint(state: 'RunState', previous: 'CheckpointMark | None' = None) -> 'tuple[str, CheckpointMark]'", + "child_broker_order_id": "function alphalab.runtime.broker_routing.child_broker_order_id(parent: 'OMSOrder', sequence: 'int') -> 'str'", + "execution_assumptions": "function alphalab.runtime.assumptions.execution_assumptions(pipeline: 'ExecutionPipelineConfig', fill_policy: 'FillPolicy', queue_model: 'QueueModel | None' = None) -> 'ExecutionAssumptions'", + "execution_report_from_broker": "function alphalab.runtime.broker_routing.execution_report_from_broker(execution: 'BrokerExecution', oms_order: 'OMSOrder', config: 'RoutingConfig') -> 'ExecutionReport'", + "live_health": "function alphalab.runtime.live.live_health(state: 'LiveRunState') -> 'tuple[str, ...]'", + "read_checkpoints": "function alphalab.runtime.checkpoint.read_checkpoints(payloads: 'Sequence[str]') -> 'RunSnapshot'", + "restore_checkpoints": "function alphalab.runtime.checkpoint.restore_checkpoints(payloads: 'Sequence[str]', objects: 'RunObjects') -> 'RunState'", + "route_child_order": "function alphalab.runtime.broker_routing.route_child_order(broker_state: 'BrokerState', broker: 'BrokerProtocol', parent: 'OMSOrder', child: 'ChildOrder', timestamp: 'float', *, mapping: 'ExternalOrderMap', children: 'ChildOrderBindings', config: 'RoutingConfig', capability: 'CompatibilityReport | None') -> 'ChildRoutingResult'", + "route_order": "function alphalab.runtime.broker_routing.route_order(broker_state: 'BrokerState', broker: 'BrokerProtocol', oms_order: 'OMSOrder', timestamp: 'float', mapping: 'ExternalOrderMap | None' = None, *, config: 'RoutingConfig', capability: 'CompatibilityReport | None' = None) -> 'RoutingResult'", + "session_close": "function alphalab.runtime.calendars.session_close(calendar: 'MarketCalendar', day: 'date') -> 'float'", + "settles_at": "function alphalab.runtime.calendars.settles_at(calendar: 'MarketCalendar', rule: 'SettlementRule', trade_day: 'date') -> 'float'", + "venue_order_type": "function alphalab.runtime.broker_routing.venue_order_type(oms_order: 'OMSOrder', routing: 'RoutingConfig') -> 'OrderType'", + "wants_slices": "function alphalab.runtime.execution_pipeline.wants_slices(strategies: 'StrategyRuntimeState') -> 'bool'" + }, + "alphalab.runtime.live_snapshot": { + "IssuedRequestRecord": "class alphalab.runtime.live_snapshot.IssuedRequestRecord(kind: \"Literal['cancel', 'modify']\", request: 'CancelRequest | ModifyRequest') -> None", + "LIVE_SCHEMA_HISTORY": "value alphalab.persistence.upgrade.SchemaHistory", + "LIVE_SNAPSHOT_SCHEMA": "value builtins.int", + "LiveRunSnapshot": "class alphalab.runtime.live_snapshot.LiveRunSnapshot(run: 'RunSnapshot', broker: 'BrokerSnapshot', venue: 'str', currency: 'str', order_type: 'OrderType', requests: 'tuple[IssuedRequestRecord, ...]' = (), held: 'tuple[str, ...]' = (), schema_version: 'int' = 2) -> None", + "capture": "function alphalab.runtime.live_snapshot.capture(state: 'LiveRunState') -> 'LiveRunSnapshot'", + "from_primitives": "function alphalab.runtime.live_snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'LiveRunSnapshot'", + "restore": "function alphalab.runtime.live_snapshot.restore(snapshot: 'LiveRunSnapshot', objects: 'RunObjects') -> 'LiveRunState'" + }, + "alphalab.runtime.run_snapshot": { + "RUN_SCHEMA_HISTORY": "value alphalab.persistence.upgrade.SchemaHistory", + "RUN_SNAPSHOT_SCHEMA": "value builtins.int", + "RunObjects": "class alphalab.runtime.run_snapshot.RunObjects(pipeline: 'RuntimeObjects', fill_policy: 'FillPolicy') -> None", + "RunSnapshot": "class alphalab.runtime.run_snapshot.RunSnapshot(pipeline: 'PipelineSnapshot', mode: 'ExecutionMode', seed: 'int | None', start_timestamp: 'float', ordering: 'OrderingGuarantee', max_market_data_age_seconds: 'float | None', years_elapsed: 'float | None', risk_free_rate: 'float', compile_analytics: 'bool', fill_policy_type: 'str', processed: 'int', current_timestamp: 'float', last_record_timestamp: 'float | None', source_id: 'str | None', steps: 'tuple[RunStep, ...]', skipped: 'tuple[SkippedRecordRecord, ...]', periods_per_year: 'float | None' = None, halt_on_strategy_failure: 'bool' = False, last_slice_at: 'float | None' = None, observations_delivered: 'int' = 0, last_observation: 'tuple[float, str] | None' = None, dropped: 'Mapping[str, int]' = , fill_policy_description: 'str | None' = None, schema_version: 'int' = 5) -> None", + "SkippedRecordRecord": "class alphalab.runtime.run_snapshot.SkippedRecordRecord(payload_type: 'str', record: 'MarketRecord', reason: 'str') -> None", + "capture": "function alphalab.runtime.run_snapshot.capture(state: 'RunState') -> 'RunSnapshot'", + "from_primitives": "function alphalab.runtime.run_snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'RunSnapshot'", + "restore": "function alphalab.runtime.run_snapshot.restore(snapshot: 'RunSnapshot', objects: 'RunObjects') -> 'RunState'" + }, + "alphalab.runtime.snapshot": { + "AnalyticsEventRecord": "class alphalab.runtime.snapshot.AnalyticsEventRecord(event_type: 'str', event: 'AnalyticsEvent') -> None", + "ExecutionEventRecord": "class alphalab.runtime.snapshot.ExecutionEventRecord(event_type: 'str', event: 'ExecutionEvent') -> None", + "MarketEventRecord": "class alphalab.runtime.snapshot.MarketEventRecord(event_type: 'str', event: 'MarketEvent') -> None", + "NOT_ASKED": "value alphalab.runtime.snapshot._NotAsked", + "PIPELINE_SCHEMA_HISTORY": "value alphalab.persistence.upgrade.SchemaHistory", + "PIPELINE_SNAPSHOT_SCHEMA": "value builtins.int", + "PipelineSnapshot": "class alphalab.runtime.snapshot.PipelineSnapshot(config: 'ConfigRecord', market: 'MarketRecord', strategy: 'tuple[StrategyRecord, ...]', strategy_events: 'tuple[StrategyEventRecord, ...]', allocation: 'AllocationSnapshot', risk: 'RiskRecord', oms: 'OMSSnapshot', execution: 'ExecutionRecord', portfolio: 'PortfolioSnapshot', analytics: 'AnalyticsRecord', market_prices: 'Mapping[str, Decimal]', fills: 'tuple[CoreFill, ...]', trades: 'tuple[CoreTrade, ...]', trade_records: 'tuple[TradeRecord, ...]', portfolio_snapshots: 'tuple[EquityPoint, ...]', unpriced_assets: 'Mapping[str, UnpricedAsset]', id_position: 'IdStreamPosition', dropped: 'Mapping[str, int]' = , schema_version: 'int' = 8) -> None", + "READABLE_PIPELINE_SCHEMAS": "value builtins.tuple", + "RETAINED_LOGS": "value builtins.tuple", + "RiskEventRecord": "class alphalab.runtime.snapshot.RiskEventRecord(event_type: 'str', event: 'RiskEvent') -> None", + "RuntimeObjects": "class alphalab.runtime.snapshot.RuntimeObjects(sizing_model: 'object', simulator: 'object', strategies: 'Mapping[str, StrategyProtocol]', instruments: 'InstrumentRegistry | None' = None) -> None", + "StrategyEventRecord": "class alphalab.runtime.snapshot.StrategyEventRecord(event_type: 'str', event: 'StrategyRuntimeEvent') -> None", + "StrategyRecord": "class alphalab.runtime.snapshot.StrategyRecord(strategy_id: 'str', status: 'StrategyStatus', config: 'Any', subscriptions: 'tuple[str, ...]', last_error: 'str | None', instance_type: 'str', state: 'StrategyStateRecord | _NotAsked | None' = NOT_ASKED, started: 'bool' = False) -> None", + "StrategyStateRecord": "class alphalab.runtime.snapshot.StrategyStateRecord(payload: 'Any', version: 'int') -> None", + "capture": "function alphalab.runtime.snapshot.capture(state: 'ExecutionPipelineState') -> 'PipelineSnapshot'", + "from_primitives": "function alphalab.runtime.snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'PipelineSnapshot'", + "restore": "function alphalab.runtime.snapshot.restore(snapshot: 'PipelineSnapshot', objects: 'RuntimeObjects') -> 'ExecutionPipelineState'" + }, + "alphalab.scenario": { + "COMMODITY_SHOCK_2022": "value alphalab.scenario.library.ScenarioDefinition", + "COVID_CRASH_2020": "value alphalab.scenario.library.ScenarioDefinition", + "CRISIS_2008": "value alphalab.scenario.library.ScenarioDefinition", + "HISTORICAL_SCENARIOS": "value builtins.dict", + "RATES_REPRICING_2022": "value alphalab.scenario.library.ScenarioDefinition", + "Requirement": "class alphalab.scenario.library.Requirement(key: 'str', kind: 'ShockKind', description: 'str') -> None", + "Scenario": "class alphalab.scenario.scenario.Scenario(name: 'str', shocks: 'tuple[Shock, ...]') -> None", + "ScenarioDefinition": "class alphalab.scenario.library.ScenarioDefinition(name: 'str', window: 'str', source_note: 'str', requirements: 'tuple[Requirement, ...]') -> None", + "ScenarioError": "class alphalab.scenario.exceptions.ScenarioError", + "ScenarioExposure": "class alphalab.scenario.exposure.ScenarioExposure(asset_id: 'str', quantity: 'Decimal', price: 'Decimal', currency: 'str', volatility: 'float | None' = None, available_liquidity: 'Decimal | None' = None, sector: 'str | None' = None) -> None", + "ScenarioResult": "class alphalab.scenario.scenario.ScenarioResult(scenario_name: 'str', scenario_identity: 'str', base_state: 'ScenarioState', shocked_state: 'ScenarioState', base_value: 'Decimal', shocked_value: 'Decimal', change_by_asset: 'Mapping[str, Decimal]', reached: 'tuple[int, ...]') -> None", + "ScenarioState": "class alphalab.scenario.exposure.ScenarioState(exposures: 'tuple[ScenarioExposure, ...]', base_currency: 'str', rates: 'Mapping[str, Decimal]') -> None", + "ScenarioValidationError": "class alphalab.scenario.exceptions.ScenarioValidationError", + "ScopeKind": "enum alphalab.scenario.shock.ScopeKind[ALL, ASSETS, SECTOR, CURRENCY]", + "Shock": "class alphalab.scenario.shock.Shock(kind: 'ShockKind', magnitude: 'Decimal', scope: 'ShockScope') -> None", + "ShockKind": "enum alphalab.scenario.shock.ShockKind[PRICE, VOLATILITY, FX, LIQUIDITY]", + "ShockScope": "class alphalab.scenario.shock.ShockScope(kind: 'ScopeKind', selector: 'tuple[str, ...]' = ()) -> None", + "UnsupportedShockError": "class alphalab.scenario.exceptions.UnsupportedShockError", + "apply_all": "function alphalab.scenario.scenario.apply_all(scenarios: 'Sequence[Scenario]', state: 'ScenarioState') -> 'tuple[ScenarioResult, ...]'", + "commodity_shock": "function alphalab.scenario.library.commodity_shock(magnitude: 'Decimal', *asset_ids: 'str') -> 'Scenario'", + "everything": "function alphalab.scenario.shock.everything() -> 'ShockScope'", + "flash_crash": "function alphalab.scenario.library.flash_crash(magnitude: 'Decimal', scope: 'ShockScope | None' = None) -> 'Scenario'", + "from_positions": "function alphalab.scenario.exposure.from_positions(positions: 'Sequence[tuple[str, Decimal, Decimal, str]]', base_currency: 'str', rates: 'Mapping[str, Decimal] | None' = None) -> 'ScenarioState'", + "fx_shock": "function alphalab.scenario.library.fx_shock(magnitude: 'Decimal', *currencies: 'str') -> 'Scenario'", + "of_assets": "function alphalab.scenario.shock.of_assets(*asset_ids: 'str') -> 'ShockScope'", + "of_currency": "function alphalab.scenario.shock.of_currency(*currencies: 'str') -> 'ShockScope'", + "of_sector": "function alphalab.scenario.shock.of_sector(*sectors: 'str') -> 'ShockScope'", + "rate_shock": "function alphalab.scenario.library.rate_shock(magnitude: 'Decimal', scope: 'ShockScope') -> 'Scenario'", + "scenario": "function alphalab.scenario.scenario.scenario(name: 'str', *, price_shock: 'Decimal | None' = None, volatility_shock: 'Decimal | None' = None, fx_shock: 'Decimal | None' = None, liquidity_shock: 'Decimal | None' = None, scope: 'ShockScope | None' = None) -> 'Scenario'", + "sector_shock": "function alphalab.scenario.library.sector_shock(magnitude: 'Decimal', *sectors: 'str') -> 'Scenario'" + }, + "alphalab.scheduler": { + "BacktestClock": "class alphalab.scheduler.clock.BacktestClock(_current_time: float) -> None", + "CRON_SEARCH_DAYS": "value builtins.int", + "ClockAdvanced": "class alphalab.scheduler.events.ClockAdvanced(event_id: str, timestamp: float, old_time: float, new_time: float) -> None", + "ClockProtocol": "class alphalab.common.time.ClockProtocol(*args, **kwargs)", + "ClockReset": "class alphalab.scheduler.events.ClockReset(event_id: str, timestamp: float, reset_time: float) -> None", + "ClockState": "class alphalab.scheduler.clock.ClockState(current_time: float) -> None", + "CronSchedule": "class alphalab.scheduler.cron.CronSchedule(expression: 'str', zone: 'str') -> None", + "InvalidClockStateError": "class alphalab.scheduler.exceptions.InvalidClockStateError", + "SESSION_SCHEDULES": "value builtins.frozenset", + "ScheduleType": "enum alphalab.scheduler.schedule.ScheduleType[ONE_SHOT, REPEATING, INTERVAL, CRON, MANUAL, SESSION_OPEN, SESSION_CLOSE]", + "ScheduledSession": "class alphalab.scheduler.session.ScheduledSession(session_id: str, start_time: float, end_time: float, phase: alphalab.scheduler.session.SessionPhase) -> None", + "SchedulerEngine": "class alphalab.scheduler.engine.SchedulerEngine()", + "SchedulerError": "class alphalab.scheduler.exceptions.SchedulerError", + "SchedulerEvent": "class alphalab.scheduler.events.SchedulerEvent(event_id: str, timestamp: float) -> None", + "SchedulerResolver": "class alphalab.scheduler.scheduler.SchedulerResolver()", + "SchedulerState": "class alphalab.scheduler.state.SchedulerState(clock: alphalab.scheduler.clock.ClockState, timers: alphalab.common.persistent_map.PersistentMap[str, alphalab.scheduler.timer.Timer] = , active_sessions: alphalab.common.persistent_map.PersistentMap[str, alphalab.scheduler.session.ScheduledSession] = , events: alphalab.common.append_log.AppendOnlyLog[alphalab.scheduler.events.SchedulerEvent] = ) -> None", + "SchedulerValidationError": "class alphalab.scheduler.exceptions.SchedulerValidationError", + "SessionEnded": "class alphalab.scheduler.events.SessionEnded(event_id: str, timestamp: float, session_id: str) -> None", + "SessionPhase": "enum alphalab.scheduler.session.SessionPhase[PRE_MARKET, REGULAR_SESSION, POST_MARKET, CLOSED]", + "SessionStarted": "class alphalab.scheduler.events.SessionStarted(event_id: str, timestamp: float, session_id: str, phase: str) -> None", + "SystemClock": "class alphalab.scheduler.clock.SystemClock()", + "Timer": "class alphalab.scheduler.timer.Timer(timer_id: str, target_timestamp: float, schedule_type: alphalab.scheduler.schedule.ScheduleType, interval: float | None = None, cron: alphalab.scheduler.cron.CronSchedule | None = None, metadata: collections.abc.Mapping[str, Any] | None = None, calendar: alphalab.data.calendar.MarketCalendar | None = None) -> None", + "TimerCancelled": "class alphalab.scheduler.events.TimerCancelled(event_id: str, timestamp: float, timer_id: str) -> None", + "TimerScheduled": "class alphalab.scheduler.events.TimerScheduled(event_id: str, timestamp: float, timer_id: str, trigger_time: float, schedule_type: str) -> None", + "TimerTriggered": "class alphalab.scheduler.events.TimerTriggered(event_id: str, timestamp: float, timer_id: str) -> None", + "VirtualClock": "class alphalab.scheduler.clock.VirtualClock(_current_time: float) -> None", + "active_sessions": "function alphalab.scheduler.views.active_sessions(state: alphalab.scheduler.state.SchedulerState) -> collections.abc.Sequence[alphalab.scheduler.session.ScheduledSession]", + "cron_timer": "function alphalab.scheduler.scheduler.cron_timer(timer_id: str, cron: alphalab.scheduler.cron.CronSchedule, after: float, metadata: dict[str, object] | None = None) -> alphalab.scheduler.timer.Timer", + "current_time": "function alphalab.scheduler.views.current_time(state: alphalab.scheduler.state.SchedulerState) -> float", + "is_session_boundary": "function alphalab.scheduler.scheduler.is_session_boundary(calendar: alphalab.data.calendar.MarketCalendar, instant: float, kind: alphalab.scheduler.schedule.ScheduleType) -> bool", + "next_session_boundary": "function alphalab.scheduler.scheduler.next_session_boundary(calendar: alphalab.data.calendar.MarketCalendar, after: float, kind: alphalab.scheduler.schedule.ScheduleType) -> float | None", + "next_timer": "function alphalab.scheduler.views.next_timer(state: alphalab.scheduler.state.SchedulerState) -> alphalab.scheduler.timer.Timer | None", + "scheduled_timers": "function alphalab.scheduler.views.scheduled_timers(state: alphalab.scheduler.state.SchedulerState) -> collections.abc.Sequence[alphalab.scheduler.timer.Timer]", + "session_timer": "function alphalab.scheduler.scheduler.session_timer(timer_id: str, kind: alphalab.scheduler.schedule.ScheduleType, calendar: alphalab.data.calendar.MarketCalendar, after: float, metadata: dict[str, object] | None = None) -> alphalab.scheduler.timer.Timer", + "validate_timer": "function alphalab.scheduler.validation.validate_timer(timer: alphalab.scheduler.timer.Timer, current_time: float) -> None" + }, + "alphalab.strategy": { + "ADAPTIVE_CONFIGURATION_SCHEME": "value builtins.str", + "ADAPTIVE_OBSERVATION_SCHEME": "value builtins.str", + "ADAPTIVE_REPLAY_SCHEME": "value builtins.str", + "ADAPTIVE_STATE_SCHEME": "value builtins.str", + "ADAPTIVE_STRATEGY_STATE_VERSION": "value builtins.int", + "AdaptationMode": "enum alphalab.strategy.adaptive.AdaptationMode[LEARNING, FROZEN]", + "AdaptiveConfiguration": "class alphalab.strategy.adaptive.AdaptiveConfiguration(name: 'str', rule_id: 'str', rule_version: 'int', inputs: 'tuple[str, ...]', parameters: 'Mapping[str, float]', cadence: 'UpdateCadence', cadence_every: 'int | None', cadence_seconds: 'float | None', decision_timing: 'DecisionTiming', warmup: 'int') -> None", + "AdaptiveDecision": "class alphalab.strategy.adaptive.AdaptiveDecision(observation_id: 'str', state_id: 'str', outputs: 'Mapping[str, float]', withheld: 'bool') -> None", + "AdaptiveObservation": "class alphalab.strategy.adaptive.AdaptiveObservation(timestamp: 'float', sequence: 'int', stream: 'str', values: 'Mapping[str, float]') -> None", + "AdaptiveOrderingError": "class alphalab.strategy.exceptions.AdaptiveOrderingError", + "AdaptiveReplay": "class alphalab.strategy.adaptive.AdaptiveReplay(configuration: 'AdaptiveConfiguration', mode: 'AdaptationMode', initial: 'AdaptiveState', stream_id: 'str', observations: 'int', transitions: 'tuple[AdaptiveTransition, ...]', decisions: 'tuple[AdaptiveDecision, ...]', final: 'AdaptiveState') -> None", + "AdaptiveRule": "class alphalab.strategy.adaptive.AdaptiveRule(*args, **kwargs)", + "AdaptiveState": "class alphalab.strategy.adaptive.AdaptiveState(configuration_id: 'str', version: 'int', observations: 'int', payload: 'Mapping[str, StateValue]', last_timestamp: 'float | None', last_sequence: 'int | None', last_adapted_at: 'float | None', lineage: 'str') -> None", + "AdaptiveStateError": "class alphalab.strategy.exceptions.AdaptiveStateError", + "AdaptiveStep": "class alphalab.strategy.adaptive.AdaptiveStep(state: 'AdaptiveState', transition: 'AdaptiveTransition', decision: 'AdaptiveDecision') -> None", + "AdaptiveStrategy": "class alphalab.strategy.adaptive_strategy.AdaptiveStrategy(strategy_id: 'str', configuration: 'AdaptiveConfiguration', rule: 'AdaptiveRule', mode: 'AdaptationMode', initial: 'AdaptiveState | None') -> 'None'", + "AdaptiveTransition": "class alphalab.strategy.adaptive.AdaptiveTransition(observation_id: 'str', kind: 'TransitionKind', before: 'str', after: 'str') -> None", + "BaseStrategy": "class alphalab.strategy.protocol.BaseStrategy()", + "CONFIGURATION_ATTRIBUTE": "value builtins.str", + "ClockProtocol": "class alphalab.common.time.ClockProtocol(*args, **kwargs)", + "DecisionTiming": "enum alphalab.strategy.adaptive.DecisionTiming[BEFORE_UPDATE, AFTER_UPDATE]", + "DiscardingLogger": "class alphalab.strategy.context.DiscardingLogger()", + "Dispatcher": "class alphalab.strategy.dispatcher.Dispatcher()", + "DuplicateStrategyError": "class alphalab.strategy.registry.DuplicateStrategyError", + "ExponentialMeanRule": "class alphalab.strategy.adaptive_rules.ExponentialMeanRule() -> None", + "FillEvent": "class alphalab.strategy.events.FillEvent(event_id: str, timestamp: float, order_id: str, instrument: str, fill_quantity: decimal.Decimal, fill_price: decimal.Decimal, side: str = '', attributed_quantity: decimal.Decimal | None = None, execution_id: str = '') -> None", + "FixedClock": "class alphalab.strategy.context.FixedClock(instant: float) -> None", + "HistoryAccessorProtocol": "class alphalab.strategy.context.HistoryAccessorProtocol(*args, **kwargs)", + "HookExecutionError": "class alphalab.strategy.exceptions.HookExecutionError", + "Intent": "class alphalab.strategy.events.Intent(strategy_id: str, instrument: str, target: decimal.Decimal, strength: decimal.Decimal = Decimal('1.0'), horizon: str = 'default', terms: alphalab.common.order_terms.OrderTerms = OrderTerms(order_type=, limit_price=None, stop_price=None, time_in_force=, expire_at=None), correlation_id: str = '', timestamp: float = 0.0, metadata: collections.abc.Mapping[str, str] = , kind: alphalab.strategy.events.IntentKind = ) -> None", + "IntentKind": "enum alphalab.strategy.events.IntentKind[DELTA, TARGET_QUANTITY, TARGET_WEIGHT]", + "InvalidIntentError": "class alphalab.strategy.exceptions.InvalidIntentError", + "InvalidTransitionError": "class alphalab.strategy.exceptions.InvalidTransitionError", + "LifecycleTransitioned": "class alphalab.strategy.events.LifecycleTransitioned(event_id: str, timestamp: float, strategy_id: str, old_state: str, new_state: str, reason: str = '') -> None", + "MarketViewProtocol": "class alphalab.strategy.context.MarketViewProtocol(*args, **kwargs)", + "NoHistory": "class alphalab.strategy.context.NoHistory() -> None", + "NoMarket": "class alphalab.strategy.context.NoMarket() -> None", + "NoOrders": "class alphalab.strategy.context.NoOrders() -> None", + "NoPortfolio": "class alphalab.strategy.context.NoPortfolio() -> None", + "NoRiskView": "class alphalab.strategy.context.NoRiskView() -> None", + "NoUniverse": "class alphalab.strategy.context.NoUniverse() -> None", + "ObservationReceived": "class alphalab.strategy.events.ObservationReceived(event_id: str, timestamp: float, delivery_id: str = '', subject: str = '', record: alphalab.common.point_in_time.StampedRecord | None = None) -> None", + "ObservationStrategyProtocol": "class alphalab.strategy.protocol.ObservationStrategyProtocol(*args, **kwargs)", + "OrderEvent": "class alphalab.strategy.events.OrderEvent(event_id: str, timestamp: float, order_id: str, instrument: str, status: str, reason: str = '', quantity: decimal.Decimal | None = None, filled_quantity: decimal.Decimal | None = None) -> None", + "OrderFacadeProtocol": "class alphalab.strategy.context.OrderFacadeProtocol(*args, **kwargs)", + "PortfolioSnapshotProtocol": "class alphalab.strategy.context.PortfolioSnapshotProtocol(*args, **kwargs)", + "RecursiveLeastSquaresRule": "class alphalab.strategy.adaptive_rules.RecursiveLeastSquaresRule() -> None", + "RiskViewProtocol": "class alphalab.strategy.context.RiskViewProtocol(*args, **kwargs)", + "RuntimeState": "class alphalab.strategy.state.RuntimeState(strategies: 'Mapping[str, StrategyState]' = , events: 'tuple[StrategyRuntimeEvent, ...]' = ) -> None", + "RuntimeSupervisor": "class alphalab.strategy.supervisor.RuntimeSupervisor()", + "SUBSCRIBE_ALL": "value builtins.str", + "ScopedLoggerProtocol": "class alphalab.strategy.context.ScopedLoggerProtocol(*args, **kwargs)", + "SliceClosed": "class alphalab.strategy.events.SliceClosed(event_id: str, timestamp: float, assets: tuple[str, ...] = ()) -> None", + "SliceStrategyProtocol": "class alphalab.strategy.protocol.SliceStrategyProtocol(*args, **kwargs)", + "StateValue": "alias StateValue", + "StrategyClassRegistry": "class alphalab.strategy.registry.StrategyClassRegistry(registrations: 'PersistentMap[str, StrategyRegistration]' = ) -> None", + "StrategyConfigurationError": "class alphalab.strategy.configuration.StrategyConfigurationError", + "StrategyContext": "class alphalab.strategy.context.StrategyContext(portfolio: alphalab.strategy.context.PortfolioSnapshotProtocol, market: alphalab.strategy.context.MarketViewProtocol, clock: alphalab.common.time.ClockProtocol, logger: alphalab.strategy.context.ScopedLoggerProtocol, risk_view: alphalab.strategy.context.RiskViewProtocol, config: Any, orders: alphalab.strategy.context.OrderFacadeProtocol, history: alphalab.strategy.context.HistoryAccessorProtocol = , universe: alphalab.strategy.context.UniverseProtocol = ) -> None", + "StrategyDeclaration": "class alphalab.strategy.registry.StrategyDeclaration(*args, **kwargs)", + "StrategyDefinition": "class alphalab.strategy.definition.StrategyDefinition(strategy_id: 'str', name: 'str', version: 'str', author: 'str', description: 'str', parameters: 'Mapping[str, ParamValue]' = , metadata: 'Mapping[str, str]' = ) -> None", + "StrategyEngine": "class alphalab.strategy.engine.StrategyEngine()", + "StrategyFactory": "class alphalab.strategy.registry.StrategyFactory(*args, **kwargs)", + "StrategyProtocol": "class alphalab.strategy.protocol.StrategyProtocol(*args, **kwargs)", + "StrategyRegistration": "class alphalab.strategy.registry.StrategyRegistration(strategy_id: 'str', factory: 'StrategyFactory', qualified_name: 'str') -> None", + "StrategyRuntimeError": "class alphalab.strategy.exceptions.StrategyRuntimeError", + "StrategyRuntimeEvent": "class alphalab.strategy.events.StrategyRuntimeEvent(event_id: str, timestamp: float) -> None", + "StrategyState": "class alphalab.strategy.state.StrategyState(strategy_id: 'str', status: 'StrategyStatus', instance: 'StrategyProtocol', config: 'Any' = None, subscriptions: 'frozenset[str]' = , last_error: 'str | None' = None, started: 'bool' = False) -> None", + "StrategyStateProtocol": "class alphalab.strategy.protocol.StrategyStateProtocol(*args, **kwargs)", + "StrategyStatus": "enum alphalab.strategy.state.StrategyStatus[CREATED, CONFIGURED, INITIALIZED, SUBSCRIBED, RUNNING, PAUSED, STOPPING, STOPPED, FAILED, DISPOSED]", + "StrategyValidationError": "class alphalab.strategy.exceptions.StrategyValidationError", + "Subscriptions": "class alphalab.strategy.subscription.Subscriptions(everything: 'bool', topics: 'frozenset[Topic]', scoped: 'frozenset[tuple[Topic, str]]') -> None", + "TimerEvent": "class alphalab.strategy.events.TimerEvent(event_id: str, timestamp: float, timer_id: str, schedule_type: str) -> None", + "Topic": "enum alphalab.strategy.subscription.Topic[TICKS, QUOTES, TRADES, BARS, FILLS, ORDERS, TIMERS, SLICES, OBSERVATIONS]", + "TrailingZScoreRule": "class alphalab.strategy.adaptive_rules.TrailingZScoreRule() -> None", + "TransitionKind": "enum alphalab.strategy.adaptive.TransitionKind[ADAPTED, OBSERVED, FROZEN]", + "UniverseProtocol": "class alphalab.strategy.context.UniverseProtocol(*args, **kwargs)", + "UnknownStrategyError": "class alphalab.strategy.registry.UnknownStrategyError", + "UpdateCadence": "enum alphalab.strategy.adaptive.UpdateCadence[EVERY_OBSERVATION, EVERY_N_OBSERVATIONS, MINIMUM_INTERVAL]", + "active_strategies": "function alphalab.strategy.views.active_strategies(state: alphalab.strategy.state.RuntimeState) -> collections.abc.Sequence[alphalab.strategy.state.StrategyState]", + "apply_update": "function alphalab.strategy.adaptive.apply_update(state: 'AdaptiveState', observation: 'AdaptiveObservation', configuration: 'AdaptiveConfiguration', rule: 'AdaptiveRule', mode: 'AdaptationMode') -> 'AdaptiveStep'", + "canonical_configuration_key": "function alphalab.strategy.adaptive.canonical_configuration_key(configuration: 'AdaptiveConfiguration') -> 'str'", + "checkpoint": "function alphalab.strategy.adaptive.checkpoint(state: 'AdaptiveState') -> 'dict[str, Any]'", + "context_factory": "function alphalab.strategy.context.context_factory(clock: alphalab.common.time.ClockProtocol, logger: alphalab.strategy.context.ScopedLoggerProtocol) -> collections.abc.Callable[[str], alphalab.strategy.context.StrategyContext]", + "create_runtime": "function alphalab.strategy.runtime.create_runtime() -> alphalab.strategy.state.RuntimeState", + "declared_configuration": "function alphalab.strategy.configuration.declared_configuration(instance: 'object') -> 'Any | None'", + "decode_configuration": "function alphalab.strategy.configuration.decode_configuration(value: 'Any', declared: 'Any', where: 'str' = 'config') -> 'Any'", + "defines_on_observation": "function alphalab.strategy.protocol.defines_on_observation(strategy: object) -> bool", + "defines_on_slice": "function alphalab.strategy.protocol.defines_on_slice(strategy: object) -> bool", + "failed_strategies": "function alphalab.strategy.views.failed_strategies(state: alphalab.strategy.state.RuntimeState) -> collections.abc.Sequence[alphalab.strategy.state.StrategyState]", + "get_strategy": "function alphalab.strategy.views.get_strategy(state: alphalab.strategy.state.RuntimeState, strategy_id: str) -> alphalab.strategy.state.StrategyState | None", + "initial_state": "function alphalab.strategy.adaptive.initial_state(configuration: 'AdaptiveConfiguration', rule: 'AdaptiveRule') -> 'AdaptiveState'", + "instances_for": "function alphalab.strategy.registry.instances_for(registry: 'StrategyClassRegistry', declarations: 'Iterable[StrategyDeclaration]') -> 'dict[str, StrategyProtocol]'", + "numeric_parameters": "function alphalab.strategy.definition.numeric_parameters(parameters: 'Mapping[str, ParamValue]') -> 'Mapping[str, float]'", + "observation_stream": "function alphalab.strategy.adaptive.observation_stream(stream: 'str', rows: 'Iterable[tuple[float, Mapping[str, float]]]', *, first_sequence: 'int') -> 'tuple[AdaptiveObservation, ...]'", + "register_strategy": "function alphalab.strategy.runtime.register_strategy(state: alphalab.strategy.state.RuntimeState, strategy_id: str, instance: alphalab.strategy.protocol.StrategyProtocol) -> alphalab.strategy.state.RuntimeState", + "replay_updates": "function alphalab.strategy.adaptive.replay_updates(configuration: 'AdaptiveConfiguration', rule: 'AdaptiveRule', observations: 'Iterable[AdaptiveObservation]', mode: 'AdaptationMode', initial: 'AdaptiveState | None' = None) -> 'AdaptiveReplay'", + "require_configuration": "function alphalab.strategy.configuration.require_configuration(value: 'Any', declared: 'Any', where: 'str' = 'config') -> 'None'", + "restore": "function alphalab.strategy.adaptive.restore(payload: 'Mapping[str, Any]', configuration: 'AdaptiveConfiguration', rule: 'AdaptiveRule') -> 'AdaptiveState'", + "runtime_for": "function alphalab.strategy.registry.runtime_for(registry: 'StrategyClassRegistry', declarations: 'Iterable[StrategyDeclaration]') -> 'RuntimeState'", + "start_strategy": "function alphalab.strategy.runtime.start_strategy(state: alphalab.strategy.state.RuntimeState, strategy_id: str, instance: alphalab.strategy.protocol.StrategyProtocol, *, config: Any, subscriptions: collections.abc.Iterable[str], at: float) -> alphalab.strategy.state.RuntimeState", + "validate_intent": "function alphalab.strategy.validation.validate_intent(intent: alphalab.strategy.events.Intent) -> None" + } + }, + "shared_names": { + "Bar": { + "bound_to": [ + "class alphalab.data.feed.Bar(symbol: str, timestamp: float, open: float, high: float, low: float, close: float, volume: float) -> None", + "class alphalab.market.bar.Bar(asset_id: 'str', timestamp: 'float', open: 'Decimal', high: 'Decimal', low: 'Decimal', close: 'Decimal', volume: 'Decimal', vwap: 'Decimal | None', trade_count: 'int | None', timeframe: 'TimeFrame') -> None" + ], + "reason": "Wire and domain: data.feed.Bar is an OHLCV aggregate as a provider reported it (a provider symbol, float prices); market.bar.Bar is the domain bar the execution path consumes (an asset id, Decimal prices, an optional vwap and trade count). One is converted into the other at ingestion; they sit on opposite sides of that conversion." + }, + "CashFlow": { + "bound_to": [ + "class alphalab.macro.bond.CashFlow(payment_date: 'date', amount: 'Decimal', is_principal: 'bool') -> None", + "class alphalab.portfolio.corporate_actions.CashFlow(kind: 'CashFlowKind', amount: 'Decimal', currency: 'str', asset_id: 'str' = '', reference: 'str' = '') -> None" + ], + "reason": "Two cash flows of two instruments: macro.bond.CashFlow is one payment a bond's schedule makes; portfolio.corporate_actions.CashFlow is cash a holding earned or cost without a trade (a dividend, a fee) booked to an account." + }, + "Dataset": { + "bound_to": [ + "class alphalab.data.dataset.Dataset(metadata: 'DatasetMetadata', schema: 'DatasetSchema', quality: 'QualityReport', records: 'tuple[CanonicalRecord, ...]' = , provenance: 'DatasetProvenance | None' = None, quality_detail: 'DataQualityReport | None' = None) -> None", + "class alphalab.ml.dataset.Dataset(feature_names: tuple[str, ...], x: tuple[tuple[float, ...], ...], y: tuple[float, ...], asset_ids: tuple[str, ...]) -> None" + ], + "reason": "A canonical price series and a design matrix: data.dataset.Dataset is one immutable, versioned dataset of market records; ml.dataset.Dataset is features and a target ready for a model. Section 13 of the shared-names test." + }, + "ExposureUpdated": { + "bound_to": [ + "class alphalab.portfolio_optimizer.events.ExposureUpdated(event_id: str, timestamp: float, portfolio_id: str) -> None", + "class alphalab.risk.events.ExposureUpdated(event_id: str, timestamp: float, gross_exposure: decimal.Decimal, net_exposure: decimal.Decimal) -> None" + ], + "reason": "Two engines' notifications: risk.events.ExposureUpdated carries the gross and net exposure the pre-trade gate computed and is persisted under the risk event tags; portfolio_optimizer.events.ExposureUpdated says a constructed portfolio's exposure was recomputed. Renaming the persisted one is a format change (tests/regression/test_persisted_enum_names.py)." + }, + "InvalidTransitionError": { + "bound_to": [ + "class alphalab.oms.exceptions.InvalidTransitionError", + "class alphalab.strategy.exceptions.InvalidTransitionError" + ], + "reason": "Each state machine's own refusal, under its own package's error base: oms.exceptions.InvalidTransitionError for an order's lifecycle, strategy.exceptions.InvalidTransitionError for a strategy instance's. Catching one deliberately does not catch the other." + }, + "MarketDataError": { + "bound_to": [ + "class alphalab.market.exceptions.MarketDataError", + "class alphalab.marketdata.exceptions.MarketDataError" + ], + "reason": "Each package's error base: market.exceptions.MarketDataError for the market data engine, marketdata.exceptions.MarketDataError for the transports that feed it." + }, + "OrderAccepted": { + "bound_to": [ + "class alphalab.broker.events.OrderAccepted(event_id: str, timestamp: float, broker_order_id: str) -> None", + "class alphalab.oms.events.OrderAccepted(event_id: str, timestamp: float, order_id: alphalab.oms.ids.OrderId) -> None" + ], + "reason": "Two sides of one order: oms.events.OrderAccepted is the OMS's lifecycle transition; broker.events.OrderAccepted is a venue's acknowledgement, which the OMS may or may not then reflect. Each is persisted under its own subsystem's event tags, so neither can be renamed without a schema step." + }, + "OrderBook": { + "bound_to": [ + "class alphalab.data.feed.OrderBook(symbol: str, timestamp: float, bids: tuple[alphalab.data.feed.OrderBookLevel, ...] = , asks: tuple[alphalab.data.feed.OrderBookLevel, ...] = ) -> None", + "class alphalab.oms.book.OrderBook(_orders: alphalab.common.persistent_map.PersistentMap[alphalab.oms.ids.OrderId, alphalab.oms.order.Order] = , _by_asset: alphalab.common.persistent_map.PersistentMap[str, alphalab.common.persistent_map.PersistentSet[alphalab.oms.ids.OrderId]] = , _by_strategy: alphalab.common.persistent_map.PersistentMap[str, alphalab.common.persistent_map.PersistentSet[alphalab.oms.ids.OrderId]] = ) -> None" + ], + "reason": "My working orders and the market's resting size: oms.book.OrderBook indexes the OMS's own orders; data.feed.OrderBook is a provider's depth snapshot. They share no operation (tests/regression/test_shared_names_stay_distinct.py, section 1)." + }, + "OrderBookLevel": { + "bound_to": [ + "class alphalab.data.feed.OrderBookLevel(price: float, size: float) -> None", + "class alphalab.market.level.OrderBookLevel(price: decimal.Decimal, size: decimal.Decimal, orders: int) -> None" + ], + "reason": "Wire and domain: data.feed.OrderBookLevel is one level of a provider's depth record (no order count); market.level.OrderBookLevel is the domain level of a market book snapshot." + }, + "OrderCancelled": { + "bound_to": [ + "class alphalab.broker.events.OrderCancelled(event_id: str, timestamp: float, broker_order_id: str) -> None", + "class alphalab.oms.events.OrderCancelled(event_id: str, timestamp: float, order_id: alphalab.oms.ids.OrderId) -> None" + ], + "reason": "The OMS's lifecycle transition and a venue's cancel confirmation: two events, each persisted under its own subsystem's tags. See OrderAccepted." + }, + "OrderId": { + "bound_to": [ + "class alphalab.oms.ids.OrderId(value: 'uuid.UUID') -> None", + "newtype alphalab.core.ids.OrderId" + ], + "reason": "Two representations of one identity across one boundary: core.ids.OrderId is the text a fill and a trade record carry and a payload persists; oms.ids.OrderId is the OMS's typed key, a UUID whose hash is computed once (ledger PRF-006). The runtime converts between them in alphalab.runtime.execution_adapters." + }, + "OrderRejected": { + "bound_to": [ + "class alphalab.broker.events.OrderRejected(event_id: str, timestamp: float, broker_order_id: str, reason: str) -> None", + "class alphalab.oms.events.OrderRejected(event_id: str, timestamp: float, order_id: alphalab.oms.ids.OrderId, reason: str) -> None" + ], + "reason": "The OMS's refusal of an order and a venue's: two events, each persisted under its own subsystem's tags. See OrderAccepted." + }, + "OrderSubmitted": { + "bound_to": [ + "class alphalab.broker.events.OrderSubmitted(event_id: str, timestamp: float, broker_order_id: str, oms_order_id: str) -> None", + "class alphalab.oms.events.OrderSubmitted(event_id: str, timestamp: float, order_id: alphalab.oms.ids.OrderId, order: alphalab.oms.order.Order) -> None" + ], + "reason": "The OMS's record that an order was submitted and the broker boundary's record that it reached a venue: two events, each persisted under its own subsystem's tags. See OrderAccepted." + }, + "PortfolioEngine": { + "bound_to": [ + "class alphalab.portfolio.engine.PortfolioEngine()", + "class alphalab.portfolio_optimizer.engine.PortfolioEngine()" + ], + "reason": "Accounting and construction: portfolio.PortfolioEngine keeps the book from fills; portfolio_optimizer.PortfolioEngine assembles target weights. Only the first is reachable from the execution path. Section 2 of the shared-names test." + }, + "PortfolioEvent": { + "bound_to": [ + "class alphalab.portfolio.events.PortfolioEvent(timestamp: float, account_id: str) -> None", + "class alphalab.portfolio_optimizer.events.PortfolioEvent(event_id: str, timestamp: float) -> None" + ], + "reason": "Each engine's event base: portfolio.events.PortfolioEvent is the base of what the accounting book records, and its subclasses are persisted under the portfolio event tags; portfolio_optimizer.events.PortfolioEvent is the construction engine's base, which no snapshot carries." + }, + "PortfolioSnapshot": { + "bound_to": [ + "class alphalab.analytics.engine.PortfolioSnapshot(timestamp: float, total_equity: decimal.Decimal, cash: decimal.Decimal, long_exposure: decimal.Decimal, short_exposure: decimal.Decimal) -> None", + "class alphalab.portfolio.snapshot.PortfolioSnapshot(account: 'Account', balances: 'Mapping[str, Decimal]', reserved: 'Mapping[str, Decimal]', positions: 'tuple[Position, ...]', transactions: 'tuple[Transaction, ...]', events: 'tuple[PortfolioEventRecord, ...]', realized_pnl: 'Mapping[str, Decimal]', commission_paid: 'Mapping[str, Decimal]', pending_marks: 'tuple[str, ...]', schema_version: 'int' = 5) -> None" + ], + "reason": "Two pictures of a portfolio for two readers: analytics.PortfolioSnapshot is one point of an equity curve (timestamp, equity, cash, exposures) that a report reads; portfolio.snapshot.PortfolioSnapshot is the persisted, versioned capture of the whole accounting state that a restore reads." + }, + "Quote": { + "bound_to": [ + "class alphalab.data.feed.Quote(symbol: str, timestamp: float, bid: float, ask: float, bid_size: float, ask_size: float) -> None", + "class alphalab.market.quote.Quote(asset_id: str, timestamp: float, bid: decimal.Decimal, ask: decimal.Decimal, bid_size: decimal.Decimal, ask_size: decimal.Decimal, venue: str, currency: str) -> None" + ], + "reason": "Wire and domain: data.feed.Quote is a provider's top of book as reported (a provider symbol, floats); market.quote.Quote is the domain quote the execution path consumes (an asset id, Decimals, a venue and a currency). Converted at ingestion." + }, + "ReportGenerated": { + "bound_to": [ + "class alphalab.analytics.events.ReportGenerated(event_id: str, timestamp: float, report_id: str, num_snapshots: int, num_trades: int) -> None", + "class alphalab.reporting.events.ReportGenerated(event_id: str, timestamp: float, report_id: str, report_type: str) -> None" + ], + "reason": "Two reports: analytics.events.ReportGenerated is a performance report compiled on the execution path and persisted under the analytics event tags; reporting.events.ReportGenerated is a document the reporting engine rendered and stored." + }, + "SessionCalendar": { + "bound_to": [ + "class alphalab.alt_data.sessions.SessionCalendar(*args, **kwargs)", + "class alphalab.futures.chain.SessionCalendar(*args, **kwargs)" + ], + "reason": "Two narrow protocols, each stating only what its reader needs from a calendar: alt_data's places an instant in a trading day (is_open, next_open, session windows), futures' only asks whether a day is a trading day, for a roll rule. data.calendar.MarketCalendar satisfies both; neither is a calendar. Section 19 of the shared-names test." + }, + "Split": { + "bound_to": [ + "class alphalab.data.feed.Split(symbol: str, timestamp: float, ratio: float) -> None", + "class alphalab.portfolio.corporate_actions.Split(asset_id: 'str', ratio: 'Decimal') -> None" + ], + "reason": "Wire and domain: data.feed.Split is a split as a corporate-action feed reports it (symbol, ratio as a float); portfolio.corporate_actions.Split is the action applied to a holding, which also expresses a reverse split and a stock dividend exactly." + }, + "Trade": { + "bound_to": [ + "class alphalab.core.trade.Trade(trade_id: alphalab.core.ids.TradeId, asset_id: alphalab.core.ids.AssetId, side: alphalab.core.enums.Side, quantity: decimal.Decimal, average_price: decimal.Decimal, fill_ids: tuple[alphalab.core.ids.FillId, ...], executed_at: float, order_id: alphalab.core.ids.OrderId | None = None) -> None", + "class alphalab.data.feed.Trade(symbol: str, timestamp: float, price: float, size: float, trade_id: str = '', aggressor: alphalab.data.feed.TradeAggressor | None = None) -> None" + ], + "reason": "Two different trades: core.trade.Trade is an execution AlphaLab's own order made, as the accounting records it; data.feed.Trade is a print a provider reported on the tape, anyone's." + }, + "all_reports": { + "bound_to": [ + "function alphalab.analytics.views.all_reports(state: alphalab.analytics.state.AnalyticsState) -> tuple[alphalab.analytics.report.PerformanceReport, ...]", + "function alphalab.execution.views.all_reports(state: alphalab.execution.state.ExecutionState) -> collections.abc.Sequence[alphalab.execution.report.ExecutionReport]" + ], + "reason": "Each package's view over its own state: analytics.all_reports(AnalyticsState) returns performance reports, execution.all_reports(ExecutionState) execution reports. The state argument says which." + }, + "capture": { + "bound_to": [ + "function alphalab.allocation.snapshot.capture(state: 'AllocationState') -> 'AllocationSnapshot'", + "function alphalab.backtesting.replay_snapshot.capture(paused: 'PausedReplay') -> 'ReplaySnapshot'", + "function alphalab.broker.snapshot.capture(state: 'BrokerState', mapping: 'ExternalOrderMap | None' = None, log: 'ReconciliationLog | None' = None) -> 'BrokerSnapshot'", + "function alphalab.instrument.snapshot.capture(registry: 'InstrumentRegistry') -> 'InstrumentRegistrySnapshot'", + "function alphalab.lifecycle.snapshot.capture(state: 'LifecycleState') -> 'LifecycleSnapshot'", + "function alphalab.oms.snapshot.capture(state: 'OMSState') -> 'OMSSnapshot'", + "function alphalab.portfolio.fx_feed.capture(state: 'FxFeedState') -> 'FxFeedSnapshot'", + "function alphalab.portfolio.snapshot.capture(state: 'PortfolioState') -> 'PortfolioSnapshot'", + "function alphalab.runtime.live_snapshot.capture(state: 'LiveRunState') -> 'LiveRunSnapshot'", + "function alphalab.runtime.run_snapshot.capture(state: 'RunState') -> 'RunSnapshot'", + "function alphalab.runtime.snapshot.capture(state: 'ExecutionPipelineState') -> 'PipelineSnapshot'" + ], + "reason": "One verb over each durable state, which the argument names: each snapshot module's capture(state) projects its own state into its own typed, versioned snapshot (docs/STATE_MODEL.md, 'Durability'), and oms re-exports its module's. The ten follow one convention -- capture, from_primitives, restore -- and are ten functions, not one." + }, + "checkpoint": { + "bound_to": [ + "function alphalab.runtime.checkpoint.checkpoint(state: 'RunState', previous: 'CheckpointMark | None' = None) -> 'tuple[str, CheckpointMark]'", + "function alphalab.strategy.adaptive.checkpoint(state: 'AdaptiveState') -> 'dict[str, Any]'" + ], + "reason": "Two checkpoints of two states: runtime.checkpoint(RunState, previous) writes the next incremental checkpoint of a run; strategy.checkpoint(AdaptiveState) gives a learned state's primitives. The argument says which." + }, + "compare_runs": { + "bound_to": [ + "function alphalab.experiment_tracking.comparison.compare_runs(tracker: alphalab.experiment_tracking.tracker.ExperimentTracker, run_ids: tuple[str, ...], metric_name: str) -> collections.abc.Mapping[str, float | None]", + "function alphalab.lifecycle.comparison.compare_runs(left: 'RunObservations', right: 'RunObservations', tolerances: 'Mapping[ComparisonMetric, Tolerance]') -> 'PairComparison'" + ], + "reason": "Two comparisons: experiment_tracking.compare_runs reads one metric across tracked runs; lifecycle.compare_runs compares two runs' observations metric by metric under declared tolerances. Different arguments, different results." + }, + "compute_pnl": { + "bound_to": [ + "function alphalab.futures.spread.compute_pnl(spread: alphalab.futures.spread.CalendarSpread, entry_prices: collections.abc.Mapping[str, decimal.Decimal], current_prices: collections.abc.Mapping[str, decimal.Decimal]) -> decimal.Decimal", + "function alphalab.options.strategy.compute_pnl(strategy: alphalab.options.strategy.OptionStrategy, entry_prices: collections.abc.Mapping[str, decimal.Decimal], spot_price: decimal.Decimal) -> decimal.Decimal" + ], + "reason": "Each instrument's own P&L: futures.compute_pnl(CalendarSpread, ...) for a calendar spread, options.compute_pnl(OptionStrategy, ...) for an options strategy at a spot price." + }, + "factor_exposure": { + "bound_to": [ + "function alphalab.analytics.decomposition.factor_exposure(positions: 'Sequence[PositionRisk]', loadings: 'Mapping[str, Mapping[str, float]]') -> 'Mapping[str, float]'", + "function alphalab.factor_library.exposure.factor_exposure(weights: 'Mapping[float, Mapping[str, float]]', groups: 'Mapping[str, str] | None' = None) -> 'ExposureReport'" + ], + "reason": "A book's and a weighting's: analytics.factor_exposure weights positions' factor loadings; factor_library.factor_exposure aggregates a weighting's exposure by asset and group. Section 39 of the shared-names test." + }, + "from_primitives": { + "bound_to": [ + "function alphalab.allocation.snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'AllocationSnapshot'", + "function alphalab.backtesting.replay_snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'ReplaySnapshot'", + "function alphalab.broker.snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'BrokerSnapshot'", + "function alphalab.instrument.snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'InstrumentRegistrySnapshot'", + "function alphalab.lifecycle.snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'LifecycleSnapshot'", + "function alphalab.oms.snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'OMSSnapshot'", + "function alphalab.portfolio.fx_feed.from_primitives(payload: 'Mapping[str, Any]') -> 'FxFeedSnapshot'", + "function alphalab.portfolio.snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'PortfolioSnapshot'", + "function alphalab.runtime.live_snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'LiveRunSnapshot'", + "function alphalab.runtime.run_snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'RunSnapshot'", + "function alphalab.runtime.snapshot.from_primitives(payload: 'Mapping[str, Any]') -> 'PipelineSnapshot'" + ], + "reason": "Each snapshot module's decoder of its own payload, upgrading through its own SchemaHistory and refusing what it does not understand (docs/STATE_MODEL.md); oms re-exports its module's. Which one reads a payload is which state the caller is restoring." + }, + "list_versions": { + "bound_to": [ + "function alphalab.feature_store.views.list_versions(state: alphalab.feature_store.state.FeatureStoreState, feature_id: str) -> collections.abc.Sequence[int]", + "function alphalab.model_registry.registry.list_versions(registry: alphalab.model_registry.registry.ModelRegistry, name: str) -> tuple[alphalab.model_registry.registry.ModelVersion, ...]" + ], + "reason": "Each registry's own versions: feature_store.list_versions(FeatureStoreState, feature_id) and model_registry.list_versions(ModelRegistry, name)." + }, + "register_strategy": { + "bound_to": [ + "function alphalab.lifecycle.registration.register_strategy(state: 'LifecycleState', name: 'str', definition: 'StrategyDefinition', timestamp: 'float', model: 'ModelRef | None' = None, run_id: 'str | None' = None, tags: 'Mapping[str, str] | None' = None) -> 'tuple[LifecycleState, StrategyVersionRef]'", + "function alphalab.strategy.runtime.register_strategy(state: alphalab.strategy.state.RuntimeState, strategy_id: str, instance: alphalab.strategy.protocol.StrategyProtocol) -> alphalab.strategy.state.RuntimeState" + ], + "reason": "Two registrations: lifecycle.register_strategy records a strategy *version* in the lifecycle registry, checking what it cites; strategy.register_strategy adds a strategy *instance* to a running session." + }, + "restore": { + "bound_to": [ + "function alphalab.allocation.snapshot.restore(snapshot: 'AllocationSnapshot') -> 'AllocationState'", + "function alphalab.backtesting.replay_snapshot.restore(snapshot: 'ReplaySnapshot', objects: 'RunObjects', dataset: 'MarketDataset', schedule: 'RunSchedule' = RunSchedule(entries=(), rate_max_age_seconds=None), execution: 'ExecutionPlan | None' = None, queue: 'QueueModel | None' = None) -> 'PausedReplay'", + "function alphalab.broker.snapshot.restore(snapshot: 'BrokerSnapshot') -> 'tuple[BrokerState, ExternalOrderMap, ReconciliationLog]'", + "function alphalab.instrument.snapshot.restore(snapshot: 'InstrumentRegistrySnapshot') -> 'InstrumentRegistry'", + "function alphalab.lifecycle.snapshot.restore(snapshot: 'LifecycleSnapshot', models: 'Mapping[str, object]') -> 'LifecycleState'", + "function alphalab.oms.snapshot.restore(snapshot: 'OMSSnapshot') -> 'OMSState'", + "function alphalab.portfolio.fx_feed.restore(snapshot: 'FxFeedSnapshot') -> 'FxFeedState'", + "function alphalab.portfolio.snapshot.restore(snapshot: 'PortfolioSnapshot') -> 'PortfolioState'", + "function alphalab.runtime.live_snapshot.restore(snapshot: 'LiveRunSnapshot', objects: 'RunObjects') -> 'LiveRunState'", + "function alphalab.runtime.run_snapshot.restore(snapshot: 'RunSnapshot', objects: 'RunObjects') -> 'RunState'", + "function alphalab.runtime.snapshot.restore(snapshot: 'PipelineSnapshot', objects: 'RuntimeObjects') -> 'ExecutionPipelineState'", + "function alphalab.strategy.adaptive.restore(payload: 'Mapping[str, Any]', configuration: 'AdaptiveConfiguration', rule: 'AdaptiveRule') -> 'AdaptiveState'" + ], + "reason": "Restorations of different states: each snapshot module's restore(snapshot) rebuilds its own state from its own snapshot -- the pipeline, run and live snapshots taking back the live objects they record, by type and configuration (docs/STATE_MODEL.md) -- and oms re-exports its module's; strategy.restore(payload, configuration, rule) rebuilds an adaptive state from strategy.checkpoint's primitives, or refuses." + }, + "rollback": { + "bound_to": [ + "function alphalab.deployment_manager.rollback.rollback(manager: alphalab.deployment_manager.releases.DeploymentManager, environment: str, timestamp: float, actor_id: str = '') -> alphalab.deployment_manager.releases.DeploymentManager", + "function alphalab.model_registry.rollback.rollback(registry: alphalab.model_registry.registry.ModelRegistry, name: str, timestamp: float) -> alphalab.model_registry.registry.ModelRegistry" + ], + "reason": "Two rollbacks: deployment_manager.rollback re-deploys an environment's previous release; model_registry.rollback archives a model's production version and restores the one before." + }, + "spread": { + "bound_to": [ + "function alphalab.macro.yield_curve.spread(curve: alphalab.macro.yield_curve.YieldCurve, short_tenor_years: decimal.Decimal, long_tenor_years: decimal.Decimal) -> decimal.Decimal | None", + "function alphalab.market.book.spread(snapshot: alphalab.market.snapshot.OrderBookSnapshot) -> decimal.Decimal" + ], + "reason": "Two spreads: macro.spread is a yield curve's long-tenor minus short-tenor yield; market.spread is an order book's best ask minus best bid." + }, + "surprise": { + "bound_to": [ + "function alphalab.alt_data.information.surprise(event: 'InformationEvent', actual: 'str', expected: 'str') -> 'Decimal'", + "function alphalab.macro.indicator.surprise(observation: alphalab.macro.indicator.IndicatorObservation) -> decimal.Decimal | None" + ], + "reason": "Two surprises: macro.surprise is an indicator's actual minus its recorded consensus; alt_data.surprise is the difference between two measurements an information event carries, which the caller names." + }, + "validate_dataset": { + "bound_to": [ + "function alphalab.api.validate_dataset(dataset: 'Dataset') -> 'tuple[ValidationFinding, ...]'", + "function alphalab.backtesting.dataset.validate_dataset(dataset: 'MarketDataset') -> 'None'" + ], + "reason": "Two datasets, two checks, named by the argument: api.validate_dataset(Dataset) re-checks an ingested canonical dataset and returns every finding; backtesting.validate_dataset(MarketDataset) checks the market dataset a run consumes and raises on the first problem. The argument types differ, so passing one to the other fails type checking." + } + } +} diff --git a/docs/api/history/README.md b/docs/api/history/README.md index 17d55dbb..afe70e56 100644 --- a/docs/api/history/README.md +++ b/docs/api/history/README.md @@ -12,6 +12,7 @@ two of them can be compared entry by entry. | `4.0.0.json` | v4.0.0 | `docs/api/public_api.json` as released: 44 packages and 11 documented modules (`alphalab.api` and the snapshot modules, recorded from v4.0, ledger API-007), 2,617 exports, 35 shared names. Against 3.13.0: four names added to `alphalab.strategy` (`start_strategy`, `context_factory`, `FixedClock`, `DiscardingLogger`), the documented modules' existing names recorded, none removed or rebound. The next release's CHANGELOG is checked against it. | | `4.1.0.json` | v4.1.0 | `docs/api/public_api.json` as released: 44 packages and 12 documented modules (`alphalab.backtesting.replay_snapshot` added), 2,628 exports, 35 shared names. Against 4.0.0: `alphalab.api.resume_replay`, `alphalab.backtesting.PausedReplay` and `ReplayProgress` and the eight names of `replay_snapshot` added; `alphalab.api.replay` given a keyword-only `pause_when`; none removed. Written by the v4.1 generator, which spells every union as PEP 604 does and `Any` unqualified (API-008): the earlier files keep the spelling of the interpreter that wrote them, and the comparison reads both sides canonically. | +| `4.2.0.json` | v4.2.0 (development; unpublished) | `docs/api/public_api.json` of the v4.2.0 development tree, copied when the maintainer set 4.2.0 as the development version and refreshed with it until 4.2.0 is published -- the release preflight requires the release's own manifest here. Against 4.1.0: names added and defaulted keywords or fields only (CHANGELOG `[4.2.0]`). | `tests/regression/test_api_changes_are_in_the_changelog.py` compares the current manifest with the newest file here older than the current release, and requires diff --git a/docs/api/public_api.json b/docs/api/public_api.json index abed9f0c..8feaff60 100644 --- a/docs/api/public_api.json +++ b/docs/api/public_api.json @@ -1,5 +1,5 @@ { - "release": "4.1.0", + "release": "4.2.0", "rule": "One name, one contract: a name two packages export is the same object in both, or it is listed under shared_names with the reason the two are deliberately different.", "packages": { "alphalab": { diff --git a/docs/audit/FUTURE_READINESS_V4_1_1.md b/docs/audit/FUTURE_READINESS_V4_1_1.md index 26ba692c..6f5baf5d 100644 --- a/docs/audit/FUTURE_READINESS_V4_1_1.md +++ b/docs/audit/FUTURE_READINESS_V4_1_1.md @@ -331,21 +331,20 @@ the FUT-002 milestone's, on the tree it committed (the FUT-011 commit's own, mea | Mutation | the FUT-011 rules and the FUT-002 checks, each broken in a scratch copy | 15 of 15 caught by `tests/integration/test_queue_model.py`; 13 of 13 by `tests/regression/test_typed_strategy_configuration.py` | | Whitespace | `git diff --check` | clean | -### 8.2 The three guards that wait for the version - -They read `alphalab.__version__`, still `4.1.0`, and fail by construction: - -* `tests/regression/test_api_changes_are_in_the_changelog.py::test_every_removed_or_changed_name_is_in_the_changelog` - compares the manifest with the newest history file older than the package's - version (4.0.0) and wants the changed names in the `[4.1.0]` entry -- the - released one, which is history and is not edited; -* `tests/regression/test_nothing_is_left_for_later.py::test_no_ledger_entry_is_assigned_beyond_this_release` - refuses ledger entries recorded for `v4.2.0` while the package says 4.1.0; -* `tests/unit/test_package_metadata.py::test_package_exposes_version` fails in the - dev venv only (stale editable metadata, as at baseline) and passes fresh. - -Choosing the version closes the first two (section 9's checklist); nothing else -depends on it. The version was not changed to make them pass. +### 8.2 The three guards that waited for the version + +Until the version was set they read `alphalab.__version__`, then `4.1.0`, and +failed by construction: `test_every_removed_or_changed_name_is_in_the_changelog` +wanted the changed names in the released `[4.1.0]` entry, and +`test_no_ledger_entry_is_assigned_beyond_this_release` refused the ledger's +`v4.2.0` entries. The maintainer approved v4.2.0 as the development and release +target (section 9); with the version set, both pass on the changes themselves -- +the CHANGELOG's `[4.2.0]` section, which the first guard showed lacked three +rows (`BrokerAssetClass`, `InstrumentRegistryRecord`, `InstrumentRegistrySnapshot`), +now added. The third, `tests/unit/test_package_metadata.py::test_package_exposes_version`, +failed in the dev venv only: its editable install had been made at 4.0.0 and +its metadata never refreshed. Reinstalled (`pip install --no-deps -e .`), it +reports 4.2.0 and passes; every fresh environment passed it throughout. ### 8.3 What was not run @@ -361,7 +360,7 @@ Windows remains untested (BDY-027). ## 9. Versioning -**Recommendation: release this candidate as v4.2.0.** It adds a public module +**Decision: v4.2.0.** The maintainer approved it as the development and release target (2026-10-30), on the grounds below; the checklist that follows is applied except where it publishes (no tag, release, upload or change to `main` is made before publication is decided). It adds a public module (`alphalab.backtesting.schedule`) and modules (`alphalab.backtesting.algorithmic`, `alphalab.backtesting.queueing`, `alphalab.execution.queue`, `alphalab.instrument.terms`, `alphalab.strategy.configuration`), names in diff --git a/docs/audit/RELEASE_CERTIFICATION.md b/docs/audit/RELEASE_CERTIFICATION.md index 4330b4e6..ce61c75a 100644 --- a/docs/audit/RELEASE_CERTIFICATION.md +++ b/docs/audit/RELEASE_CERTIFICATION.md @@ -1,4 +1,4 @@ -# Release certification: AlphaLab 4.1.0 +# Release certification: AlphaLab 4.2.0 Generated by `python docs/audit/scripts/certify_release.py` (ledger FEA-006) from `release_certification.json`, which is the certificate; this page renders it. CI runs the same script with `--check`, which fails when a check fails, when a check's evidence differs from what is committed here, or when the source digest below is not the build's. @@ -6,8 +6,8 @@ Generated by `python docs/audit/scripts/certify_release.py` (ledger FEA-006) fro | Build | | | --- | --- | -| Engine | alphalab 4.1.0 | -| Source digest | `5c65ed1b1d17ebff15766aa3f27f1bd611bddb76a897dc5c77ee55770f40a364` | +| Engine | alphalab 4.2.0 | +| Source digest | `c8f5486d98da83942e4ff6ffed55083bc8ab5769f2f037501b863b7f643ee5c1` | | Time-zone database | 2024a | | Certified on | Python 3.12.4 (CPython), macOS-27.0.1-arm64-arm-64bit | @@ -105,7 +105,7 @@ Generated by `python docs/audit/scripts/certify_release.py` (ledger FEA-006) fro ### API-1 -- `manifest_digest`: `3cba4042a14c70f0c3b6c2c2a4a5c71c09748a96de28063bb4665e26509015b3` +- `manifest_digest`: `c7e0ce00f108aa8ecc2e2f5d4418844046fbceee7024cac4921a737cc9c6e09e` - `packages`: `44` - `documented_modules`: `12` - `exports`: `2683` diff --git a/docs/audit/release_certification.json b/docs/audit/release_certification.json index 509b37e9..faf08466 100644 --- a/docs/audit/release_certification.json +++ b/docs/audit/release_certification.json @@ -1,8 +1,8 @@ { - "release": "4.1.0", + "release": "4.2.0", "build": { - "engine": "alphalab 4.1.0", - "source_digest": "5c65ed1b1d17ebff15766aa3f27f1bd611bddb76a897dc5c77ee55770f40a364", + "engine": "alphalab 4.2.0", + "source_digest": "c8f5486d98da83942e4ff6ffed55083bc8ab5769f2f037501b863b7f643ee5c1", "tz_database": "2024a" }, "environment": { @@ -133,7 +133,7 @@ "claim": "The build's public API is the one docs/api/public_api.json records for this release.", "passed": true, "evidence": { - "manifest_digest": "3cba4042a14c70f0c3b6c2c2a4a5c71c09748a96de28063bb4665e26509015b3", + "manifest_digest": "c7e0ce00f108aa8ecc2e2f5d4418844046fbceee7024cac4921a737cc9c6e09e", "packages": 44, "documented_modules": 12, "exports": 2683, diff --git a/nowandfuture.md b/nowandfuture.md index c8e9caf8..97505932 100644 --- a/nowandfuture.md +++ b/nowandfuture.md @@ -1,6 +1,6 @@ # AlphaLab — Now and Future -**A long-term project reference, written at v3.0.0 and updated at v4.1.0.** +**A long-term project reference, written at v3.0.0 and updated at v4.2.0.** This document exists so that a future engineer — including a future version of the person who wrote AlphaLab — can answer these questions without reconstructing @@ -42,7 +42,7 @@ database, and why a security review of AlphaLab is a review of AlphaLab. | | | | --- | --- | -| Version | **4.1.0** | +| Version | **4.2.0** | | Python | 3.12+ | | License | MIT | | Author | Varun Kumar Singh | diff --git a/tests/regression/test_release_preflight.py b/tests/regression/test_release_preflight.py index 79525a2d..44fc7cd5 100644 --- a/tests/regression/test_release_preflight.py +++ b/tests/regression/test_release_preflight.py @@ -60,7 +60,7 @@ def _job(name: str) -> str: #: The candidate branch a push may validate: named once in the trigger, once in the guard. -CANDIDATE_BRANCH = "work/v4.1.0-release-hardening" +CANDIDATE_BRANCH = "work/v4.2.0-release" def _triggers() -> str: @@ -789,13 +789,13 @@ def test_the_candidate_s_release_is_the_one_the_push_branch_names() -> None: @pytest.mark.parametrize( ("event", "dispatched", "tree_version", "outcome"), [ - ("push", "", "4.1.1", "but 4.1.0 was expected"), - ("pull_request", "", "4.1.1", "but 4.1.0 was expected"), - ("push", "", "4.1.0", "version=4.1.0"), - ("pull_request", "", "4.1.0", "version=4.1.0"), - ("workflow_dispatch", "4.1.1", "4.1.1", "version=4.1.1"), - ("workflow_dispatch", "4.1.0", "4.1.1", "but 4.1.0 was expected"), - ("workflow_dispatch", "v4.1.0", "4.1.0", "is not MAJOR.MINOR.PATCH"), + ("push", "", "4.2.1", "but 4.2.0 was expected"), + ("pull_request", "", "4.2.1", "but 4.2.0 was expected"), + ("push", "", "4.2.0", "version=4.2.0"), + ("pull_request", "", "4.2.0", "version=4.2.0"), + ("workflow_dispatch", "4.2.1", "4.2.1", "version=4.2.1"), + ("workflow_dispatch", "4.2.0", "4.2.1", "but 4.2.0 was expected"), + ("workflow_dispatch", "v4.2.0", "4.2.0", "is not MAJOR.MINOR.PATCH"), ], ) def test_push_and_pull_request_runs_require_the_release_and_a_manual_run_names_its_own( @@ -803,8 +803,8 @@ def test_push_and_pull_request_runs_require_the_release_and_a_manual_run_names_i ) -> None: """The identity step's own script, in a tree whose every reference says ``tree_version``. - A push or a labelled pull request must find 4.1.0 (RELEASE_VERSION); a tree - that says 4.1.1 throughout fails though it agrees with itself. A manual run + A push or a labelled pull request must find 4.2.0 (RELEASE_VERSION); a tree + that says 4.2.1 throughout fails though it agrees with itself. A manual run keeps its own ``expected_version``, which the tree must match. """ diff --git a/tests/unit/test_package_metadata.py b/tests/unit/test_package_metadata.py index d19d8b8f..79b483c2 100644 --- a/tests/unit/test_package_metadata.py +++ b/tests/unit/test_package_metadata.py @@ -30,4 +30,4 @@ def test_the_declared_version_is_the_release_version() -> None: """ from alphalab.common._version import __version__ - assert __version__ == "4.1.0" + assert __version__ == "4.2.0" From 2b9ef14e8cec53e662caa634547f4a29f7ea0b06 Mon Sep 17 00:00:00 2001 From: VarunSingh022 Date: Sat, 31 Oct 2026 09:55:07 +0530 Subject: [PATCH 05/56] feat(strategy): a strategy exercises its own options from its hooks (FUT-014) A strategy can now decide inside the run to exercise an option it holds. How a strategy asks - Any hook can return Intent(kind=IntentKind.EXERCISE, instrument=