diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 8d333ea..489aadf 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -10,7 +10,9 @@ body: - type: markdown attributes: value: | - Search existing issues first. Do not disclose suspected vulnerabilities here. + Follow the [contribution guide](https://github.com/ml4t/live/blob/main/CONTRIBUTING.md). + Search existing issues and describe one problem or coherent change per issue. + Report suspected vulnerabilities privately, not in this form. - type: input id: version attributes: @@ -63,3 +65,11 @@ body: description: State the observable conditions that demonstrate the issue is resolved. validations: required: true + - type: textarea + id: ai-assistance + attributes: + label: AI assistance and accountability + description: State whether AI helped with this report and how. Confirm you checked the facts and can explain the reproduction or requested outcome. + placeholder: "None, or describe the assistance and your verification." + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 3980c19..7e1ff25 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -4,5 +4,8 @@ contact_links: url: https://github.com/ml4t/live/security/advisories/new about: Report suspected vulnerabilities privately. - name: Usage questions - url: https://github.com/orgs/ml4t/discussions - about: Ask questions that do not describe a defect or requested change. + url: https://www.ml4trading.io/docs/live/ + about: Read the usage documentation before opening a focused issue. + - name: Contribution guide + url: https://github.com/ml4t/live/blob/main/CONTRIBUTING.md + about: Read the shared contribution requirements and repository-specific checks. diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml index e03f26d..cd72878 100644 --- a/.github/ISSUE_TEMPLATE/documentation.yml +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -7,6 +7,12 @@ labels: - "status: needs-triage" - "compatibility: none" body: + - type: markdown + attributes: + value: | + Follow the [contribution guide](https://github.com/ml4t/live/blob/main/CONTRIBUTING.md). + Search existing issues and describe one problem or coherent change per issue. + Report suspected vulnerabilities privately, not in this form. - type: input id: version attributes: @@ -38,3 +44,11 @@ body: label: Acceptance criteria validations: required: true + - type: textarea + id: ai-assistance + attributes: + label: AI assistance and accountability + description: State whether AI helped with this report and how. Confirm you checked the facts and can explain the reproduction or requested outcome. + placeholder: "None, or describe the assistance and your verification." + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml index da95266..5b2f261 100644 --- a/.github/ISSUE_TEMPLATE/feature.yml +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -7,6 +7,12 @@ labels: - "status: needs-triage" - "compatibility: affected" body: + - type: markdown + attributes: + value: | + Follow the [contribution guide](https://github.com/ml4t/live/blob/main/CONTRIBUTING.md). + Search existing issues and describe one problem or coherent change per issue. + Report suspected vulnerabilities privately, not in this form. - type: input id: version attributes: @@ -49,3 +55,11 @@ body: label: Acceptance criteria validations: required: true + - type: textarea + id: ai-assistance + attributes: + label: AI assistance and accountability + description: State whether AI helped with this report and how. Confirm you checked the facts and can explain the reproduction or requested outcome. + placeholder: "None, or describe the assistance and your verification." + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 0d92431..89719dc 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,7 +1,13 @@ +Follow the [local contribution guide](https://github.com/ml4t/live/blob/main/CONTRIBUTING.md) and shared policy linked there. +External submissions should address one problem or coherent change. Maintainer-directed maintenance +may batch fixes and related documentation; reference all affected issues and verify the combined result. + ## Owning issue Closes # +Automated dependency updates and administrative metadata changes do not require a separate issue. + ## Outcome Describe the user-visible or standards outcome. @@ -13,7 +19,8 @@ Describe the user-visible or standards outcome. ## Verification -- [ ] Tests reproduce the prior failure or verify the requested behavior +- [ ] For a defect, the same public-workflow regression fails on the relevant base and passes on the fix +- [ ] Exact base/fix revisions, commands, and results are included; documentation uses relevant checks - [ ] Ruff lint and format checks pass - [ ] `ty` passes - [ ] Test suite passes @@ -24,3 +31,9 @@ Describe the user-visible or standards outcome. ## Documentation and release State the documentation, release notes, and patch-release work required after merge. + +## AI assistance and accountability + +State whether AI assisted with code or prose and identify its contribution. + +- [ ] I reviewed and tested this submission and can explain its implementation and evidence diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bee7009..174325f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,4 +21,4 @@ jobs: uses: ./.github/workflows/stable-qualification.yml with: candidate-sha: ${{ github.event.pull_request.head.sha || github.sha }} - candidate-version: "0.1.2" + candidate-version: "0.1.3" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 053b090..44b111d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -15,7 +15,7 @@ concurrency: env: CANDIDATE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - CANDIDATE_VERSION: "0.1.1" + CANDIDATE_VERSION: "0.1.3" jobs: build: diff --git a/.github/workflows/paper.yml b/.github/workflows/paper.yml index 61cc262..3a9ba0f 100644 --- a/.github/workflows/paper.yml +++ b/.github/workflows/paper.yml @@ -17,6 +17,11 @@ on: default: all type: choice options: [alpaca, ib, okx, all] + publish-evidence: + description: Publish a durable evidence prerelease (requires separate maintainer authorization) + required: false + default: false + type: boolean permissions: {} @@ -305,6 +310,7 @@ jobs: archive: name: Retain provider evidence needs: paper + if: ${{ inputs.publish-evidence }} runs-on: ubuntu-latest permissions: actions: read diff --git a/AGENTS.md b/AGENTS.md index f78aee5..b0100fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,3 +55,23 @@ uv run ty check uv run pytest uv run mkdocs build --strict ``` + +## Maintainer-directed change batching + +For our own fixes and agent work directed by the maintainer, use a bounded, coherent PR rather +than one PR per small defect or implementation step. One PR may close several related issues +when the changes serve the same user workflow, subsystem repair or compatibility objective and +can be reviewed and reverted together. Keep implementation, regressions, documentation and +follow-up repairs together; use focused commits and retain evidence for each defect. Do not +create separate issues or PRs merely for individual files, tests, assertions or platform variants. + +Split unrelated changes, independently risky or breaking changes, separate ownership boundaries, +and work that needs independent release or rollback decisions. A general maintenance label alone +does not make a batch coherent. Reuse or safely consolidate existing owned submissions instead of +opening overlapping PRs; preserve contributor attribution, recoverable history and other owners' +active work. Do not rewrite or close someone else's work merely to reduce the queue count. + +Run focused checks while iterating, then qualify the complete final revision with every applicable +required gate. Group related work before expensive CI; do not weaken checks, bypass review or +claim old-head checks qualify changed code. This maintainer-directed batching rule takes precedence +over a literal one-issue-per-PR reading of contributor intake guidance. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..eb544ee --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Contributing to ML4T Live + +External submissions follow the +[shared ML4T contribution policy](https://github.com/ml4t/ecosystem/blob/main/CONTRIBUTING.md). +Search existing issues first. Describe one problem or coherent change per issue and submit one +focused PR linked to its owning issue. Automated dependency updates and administrative metadata +changes retain the shared exception. + +Maintainer-directed maintenance may batch fixes and related documentation in one PR linked to all +affected issues. It does not require a separate PR for each issue or follow-up edit. Preserve each +fix's regression evidence and contributor attribution, and run applicable checks on the combined +result before merging. + +For defects, provide a minimal reproduction through the public API or user workflow, package and +environment details, and expected behavior. Record a regression failing on the relevant base and +passing on the fix, with exact revisions, commands, and results. Correct verified numerical defects; +agree on intentional changes to definitions, annualization, signs, or defaults in the owning issue +and describe their compatibility impact. Documentation changes use relevant documentation checks. + +Disclose whether AI assisted with code or prose and identify its contribution. Review and test your +submission yourself, and be prepared to explain its implementation, evidence, and review responses. + +Start integration work in shadow or paper mode. Preserve explicit live-routing selection, +provider qualification, recovery behavior, and credential isolation. +Use the repository's [quality commands](AGENTS.md#quality-commands) and report their outcomes. +Report suspected vulnerabilities through +[private security reporting](https://github.com/ml4t/live/security/advisories/new). diff --git a/README.md b/README.md index 3db8736..e62427d 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ shared lifecycle and canonical intent contracts. See the ## Development +Read the [contribution guide](CONTRIBUTING.md) before opening an issue or pull request. + ```bash git clone https://github.com/ml4t/live.git cd live diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 9d56150..d1cf137 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -11,9 +11,12 @@ Get the maintained example from the public repository, then run it with the publ ```bash git clone https://github.com/ml4t/live.git cd live -uv run --no-project --with ml4t-live python examples/shadow_mode_demo.py +uv run --isolated --no-project --with ml4t-live python examples/shadow_mode_demo.py ``` +`--isolated` keeps an existing checkout environment from substituting an editable development +package for the published package. + The script defines a synthetic feed that emits one `DEMO` bar per second for 65 seconds. A moving average strategy submits a buy or sell when its signal changes. `SafeBroker` records virtual fills and positions under `execution_mode="shadow"`; the example broker raises if it receives a real diff --git a/docs/qualification.md b/docs/qualification.md index 35091c5..7bddb75 100644 --- a/docs/qualification.md +++ b/docs/qualification.md @@ -70,9 +70,13 @@ provider's result. Documentation, metadata, workflows, generic build tools, and the provider's reachable contract do not invalidate it. Missing inputs or an unsupported identity schema fail closed. -The workflow stores redacted evidence as a 90-day Actions artifact and as a durable prerelease -asset named `provider-evidence-RUN_ID/provider-evidence.zip`. The release gate takes wheel and -source-distribution hashes from the exact candidate artifacts. It then selects complete lifecycle, +The workflow stores redacted evidence as a 90-day Actions artifact. Leave `publish-evidence` at +its default `false` to qualify without creating a GitHub release. Publishing a durable prerelease +asset named `provider-evidence-RUN_ID/provider-evidence.zip` requires separate maintainer +authorization and an explicit `publish-evidence: true` request. Retained, validated evidence can +also be archived after qualification once publication is authorized, without repeating the soak. +The release gate takes artifact hashes from the exact candidate wheel and source distribution. +It then selects complete lifecycle, restart, and soak evidence independently for each matching provider contract. Provider evidence may come from different current or earlier runs. Evidence has no time-based expiry. Missing, malformed, mismatched, or unclassifiable evidence fails closed before publication. The Aug 10 run at diff --git a/docs/user-guide/backtest-to-live.md b/docs/user-guide/backtest-to-live.md index 7185785..0e33637 100644 --- a/docs/user-guide/backtest-to-live.md +++ b/docs/user-guide/backtest-to-live.md @@ -103,3 +103,29 @@ virtual positions, and orderly shutdown without a venue. Then compare the same s and canonical intents in Backtest and Live on equivalent input. Record data cutoffs, execution policy, starting positions, fees, and venue capabilities before interpreting outcome differences. The [API reference](../api/index.md#engine) gives the Live engine signature. + +## Run the installed-candidate comparison + +From a Linux checkout of this repository with `uv` installed, run: + +```bash +uv run --locked python scripts/qualification/check_dependency_matrix.py +``` + +The verifier builds the checkout's Live wheel, installs it into separate minimum, locked, and +maximum dependency environments, and runs the shared strategy through the real `Engine` and +`LiveEngine` contract tests. It also checks canonical order and broker contracts. The output names +the installed package versions for each profile; a failed contract or installation returns a +nonzero exit status. No provider credentials or venue orders are required. + +The published shadow quickstart stack checked on 2026-10-17 was: + +| Package | Version | +| --- | --- | +| `ml4t-live` | `0.1.2` | +| `ml4t-backtest` | `0.1.12` | +| `ml4t-specs` | `0.1.5` | + +The complete shadow quickstart passed on that stack. The command above builds the current +checkout instead; use its reported versions when recording a new parity or migration result. +Neither result establishes equal venue execution or qualifies a later dependency release. diff --git a/docs/user-guide/feeds.md b/docs/user-guide/feeds.md index 0311e7d..a296611 100644 --- a/docs/user-guide/feeds.md +++ b/docs/user-guide/feeds.md @@ -188,6 +188,8 @@ Optional symbol filtering: feed = BarAggregator(raw_feed, bar_size_minutes=5, assets=["SPY", "QQQ"]) ``` +Intervals align to `1970-01-01T00:00:00Z` and keep their configured duration across UTC midnight. + The aggregator emits one event per asset so sparse symbols retain independent interval boundaries. An elapsed interval emits once as `complete`. If shutdown occurs before the current interval ends, its buffered value emits once as `evolving`. Late input cannot reopen an already completed interval. diff --git a/docs/user-guide/migration.md b/docs/user-guide/migration.md index da382a4..cbe3dd8 100644 --- a/docs/user-guide/migration.md +++ b/docs/user-guide/migration.md @@ -72,6 +72,10 @@ or different target policy is a migration failure even when both strategies emit See [Backtest to Live](backtest-to-live.md) for the portability boundary and [Candidate Qualification](../qualification.md) for the complete package gate. +Run the [installed-candidate comparison](backtest-to-live.md#run-the-installed-candidate-comparison) +to exercise both engine dispatch paths and record the resolved dependency versions. The same +section names the published shadow reference stack; record separate versions for a new parity run. + ## Migrate From The Published Beta The published `0.1.0b3` package remains the prerelease API migration baseline. The first stable diff --git a/scripts/qualification/check_workflows.py b/scripts/qualification/check_workflows.py index 4ca28cf..4d68537 100644 --- a/scripts/qualification/check_workflows.py +++ b/scripts/qualification/check_workflows.py @@ -547,7 +547,7 @@ def validate_workflows(root: Path = WORKFLOW_ROOT) -> list[str]: failures.append("CI does not call the reusable qualification workflow") if ci_qualification.get("with") != { "candidate-sha": "${{ github.event.pull_request.head.sha || github.sha }}", - "candidate-version": "0.1.2", + "candidate-version": "0.1.3", }: failures.append("CI does not bind the reusable qualification candidate") @@ -659,6 +659,16 @@ def validate_workflows(root: Path = WORKFLOW_ROOT) -> list[str]: failures.append("paper qualification does not retain its evidence bundle") archive_job = paper.get("jobs", {}).get("archive", {}) + publication_input = ( + paper.get("on", {}) + .get("workflow_dispatch", {}) + .get("inputs", {}) + .get("publish-evidence", {}) + ) + if publication_input.get("type") != "boolean" or publication_input.get("default") != "false": + failures.append("provider evidence publication is not an explicit opt-in") + if archive_job.get("if") != "${{ inputs.publish-evidence }}": + failures.append("provider evidence can publish without an explicit request") if _needs(archive_job) != {"paper"} or archive_job.get("runs-on") != "ubuntu-latest": failures.append("provider evidence is not archived by a hosted job after qualification") archive_text = json.dumps(archive_job, sort_keys=True) diff --git a/src/ml4t/live/brokers/alpaca.py b/src/ml4t/live/brokers/alpaca.py index b06c1cc..42bf7d9 100644 --- a/src/ml4t/live/brokers/alpaca.py +++ b/src/ml4t/live/brokers/alpaca.py @@ -21,6 +21,7 @@ from alpaca.trading.client import TradingClient from alpaca.trading.enums import OrderSide as AlpacaOrderSide from alpaca.trading.enums import OrderStatus as AlpacaOrderStatus +from alpaca.trading.enums import PositionSide as AlpacaPositionSide from alpaca.trading.enums import QueryOrderStatus, TimeInForce from alpaca.trading.requests import ( GetOrdersRequest, @@ -201,9 +202,6 @@ async def connect(self) -> None: async def disconnect(self) -> None: """Disconnect from Alpaca.""" - if not self._connected: - return - stream_task = self._stream_task if self._trading_stream: try: @@ -769,8 +767,6 @@ async def _on_trade_update(self, data: Any) -> None: return if event == "fill": - if filled_quantity == 0: - filled_quantity = order.quantity if ( filled_quantity != order.quantity or not math.isfinite(filled_price) @@ -906,6 +902,12 @@ async def _sync_positions(self) -> None: raise RuntimeError("Alpaca positions snapshot contains an empty symbol") if not math.isfinite(quantity) or quantity == 0: raise RuntimeError("Alpaca positions snapshot contains an invalid quantity") + if pos.side == AlpacaPositionSide.SHORT: + quantity = -abs(quantity) + elif pos.side != AlpacaPositionSide.LONG: + raise RuntimeError("Alpaca positions snapshot contains an invalid side") + else: + quantity = abs(quantity) if not math.isfinite(entry_price) or entry_price < 0: raise RuntimeError("Alpaca positions snapshot contains an invalid entry price") if not math.isfinite(current_price) or current_price < 0: diff --git a/src/ml4t/live/feeds/aggregator.py b/src/ml4t/live/feeds/aggregator.py index 1516d1a..e2f36c3 100644 --- a/src/ml4t/live/feeds/aggregator.py +++ b/src/ml4t/live/feeds/aggregator.py @@ -323,7 +323,7 @@ def _signal_stop(self) -> None: self._queue.finish(discard=False) def _truncate_to_bar(self, dt: datetime) -> datetime: - """Truncate datetime to bar boundary. + """Truncate datetime to a fixed UTC epoch-aligned bar boundary. Args: dt: Datetime to truncate @@ -331,9 +331,8 @@ def _truncate_to_bar(self, dt: datetime) -> datetime: Returns: Datetime truncated to bar boundary (e.g., 10:35:42 -> 10:35:00 for 1-min bars) """ - minutes_per_bar = int(self.bar_size.total_seconds() // 60) - truncated_minutes = (dt.minute // minutes_per_bar) * minutes_per_bar - return dt.replace(minute=truncated_minutes, second=0, microsecond=0) + epoch = datetime(1970, 1, 1, tzinfo=UTC) + return epoch + ((dt - epoch) // self.bar_size) * self.bar_size async def _emit_asset_bar( self, diff --git a/src/ml4t/live/safety.py b/src/ml4t/live/safety.py index 17bfad7..45e726d 100644 --- a/src/ml4t/live/safety.py +++ b/src/ml4t/live/safety.py @@ -24,6 +24,7 @@ from enum import StrEnum from pathlib import Path from typing import Any +from uuid import uuid4 from ml4t.backtest.types import Order, OrderSide, OrderStatus, OrderType, Position from ml4t.specs import ExecutionCapability @@ -982,9 +983,10 @@ def get_position(self, asset: str) -> Position | None: Returns: Position object or None """ + normalized = asset.strip().upper() if self.config.shadow_mode: - return self._virtual_portfolio.positions.get(asset) - return self._broker.positions.get(asset) + return self._virtual_portfolio.positions.get(normalized) + return self._broker.positions.get(normalized) def get_positions(self) -> dict[str, Position]: """Get all current positions through the portable strategy facade.""" @@ -1130,7 +1132,7 @@ async def _reduce_position_unlocked( side=request.side, quantity=request.quantity, order_type=OrderType.MARKET, - order_id=f"SHADOW-REDUCE-{int(time.time() * 1_000_000)}", + order_id=self._new_shadow_order_id("SHADOW-REDUCE"), status=OrderStatus.FILLED, filled_quantity=request.quantity, filled_price=price, @@ -1440,7 +1442,7 @@ async def _submit_order_unlocked( order_type=order_type, limit_price=limit_price, stop_price=stop_price, - order_id=f"SHADOW-{int(time.time() * 1000)}", + order_id=self._new_shadow_order_id("SHADOW"), status=OrderStatus.FILLED, filled_quantity=quantity, filled_price=price, @@ -1510,6 +1512,10 @@ async def _submit_order_unlocked( return order + def _new_shadow_order_id(self, prefix: str) -> str: + """Keep shadow identities distinct across clock ticks and process restarts.""" + return f"{prefix}-{uuid4().hex}" + def _commit_accepted_order(self, request: CanonicalOrderRequest) -> None: """Commit counters and histories only after an order is accepted.""" now = time.time() @@ -2655,6 +2661,7 @@ async def get_position_async(self, asset: str) -> Position | None: Returns: Position object or None """ + normalized = asset.strip().upper() if self.config.shadow_mode: - return self._virtual_portfolio.positions.get(asset) - return await self._broker.get_position_async(asset) + return self._virtual_portfolio.positions.get(normalized) + return await self._broker.get_position_async(normalized) diff --git a/src/ml4t/live/wrappers.py b/src/ml4t/live/wrappers.py index 684d64b..9617215 100644 --- a/src/ml4t/live/wrappers.py +++ b/src/ml4t/live/wrappers.py @@ -125,8 +125,8 @@ def get_position(self, asset: str) -> Position | None: TimeoutError: If operation times out RuntimeError: If broker error occurs """ - # Can use positions property since it returns a copy - return self.positions.get(asset) + # Position maps are keyed by the canonical uppercase symbol. + return self.positions.get(asset.strip().upper()) def get_positions(self) -> dict[str, Position]: """Get all current positions through the portable strategy facade.""" diff --git a/tests/contracts/test_causal_strategy_parity.py b/tests/contracts/test_causal_strategy_parity.py index f1941a8..e78e27b 100644 --- a/tests/contracts/test_causal_strategy_parity.py +++ b/tests/contracts/test_causal_strategy_parity.py @@ -420,6 +420,68 @@ async def test_scheduled_target_matches_when_observable_state_is_equivalent(tmp_ assert live.strategy_trace.observations == backtest.strategy_trace.observations +@pytest.mark.asyncio +@pytest.mark.parametrize("symbol", ["spy", " spy "]) +async def test_lowercase_close_reduces_the_position_created_by_the_strategy( + tmp_path, symbol +) -> None: + class CloseStrategy(Strategy): + def __init__(self): + self.observations = [] + + def on_data(self, timestamp, data, context, broker): + if not self.observations: + broker.submit_order(symbol, 1) + self.observations.append(broker.get_position("SPY").quantity) + else: + position = broker.get_position(symbol) + self.observations.append(None if position is None else position.quantity) + broker.close_position(symbol) + + safe = SafeBroker( + DeterministicLiveBroker(), + LiveRiskConfig(execution_mode="shadow", state_file=str(tmp_path / "state.json")), + ) + strategy = CloseStrategy() + engine = LiveEngine(strategy, safe, DeterministicLiveFeed(market_events())) + await engine.connect() + await engine.run() + + assert strategy.observations == [1, 1] + assert safe.positions == {} + + +@pytest.mark.asyncio +async def test_shadow_order_identities_do_not_collide_within_ticks_or_after_restart( + tmp_path, monkeypatch +) -> None: + monkeypatch.setattr("ml4t.live.safety.time.time", lambda: 1_700_000_000.0) + orders = [] + + class SubmitStrategy(Strategy): + def on_data(self, timestamp, data, context, broker): + orders.append(broker.submit_order("SPY", 1)) + orders.append(broker.submit_order("SPY", 1)) + orders.append(broker.close_position("SPY")) + + for _ in range(2): + safe = SafeBroker( + DeterministicLiveBroker(), + LiveRiskConfig( + execution_mode="shadow", + dedup_window_seconds=0, + state_file=str(tmp_path / "state.json"), + ), + ) + engine = LiveEngine(SubmitStrategy(), safe, DeterministicLiveFeed(market_events()[:1])) + await engine.connect() + await engine.run() + assert safe.positions == {} + + assert len(orders) == 6 + assert len({order.order_id for order in orders}) == 6 + + @pytest.mark.asyncio async def test_late_same_session_target_is_rejected_without_submission_in_both_engines( tmp_path, diff --git a/tests/unit/test_alpaca_broker.py b/tests/unit/test_alpaca_broker.py index 95822a1..a7db9bd 100644 --- a/tests/unit/test_alpaca_broker.py +++ b/tests/unit/test_alpaca_broker.py @@ -1,5 +1,7 @@ """Unit tests for AlpacaBroker connection and setup.""" +import asyncio +import threading import time from datetime import UTC, datetime from types import SimpleNamespace @@ -10,6 +12,9 @@ from alpaca.common.enums import BaseURL from alpaca.trading.enums import OrderStatus as AlpacaOrderStatus from alpaca.trading.enums import TimeInForce +from alpaca.trading.models import Order as AlpacaOrder +from alpaca.trading.models import Position as AlpacaPosition +from alpaca.trading.models import TradeUpdate as AlpacaTradeUpdate from alpaca.trading.requests import MarketOrderRequest from ml4t.backtest.types import Order, OrderSide, OrderStatus, OrderType, Position @@ -215,6 +220,59 @@ async def test_sync_positions_accepts_zero_cost_basis_and_current_price(self): assert broker.positions["AAPL"].entry_price == 0.0 assert broker.positions["AAPL"].current_price == 0.0 + @pytest.mark.asyncio + async def test_sync_positions_signs_short_holdings(self): + """Alpaca reports an unsigned qty plus a long/short side.""" + client = MagicMock() + client.get_all_positions.return_value = [ + MockAlpacaPosition("AAPL", "50", "160.00", side="short", current_price="155.00"), + ] + broker = AlpacaBroker(api_key="PKTEST", secret_key="SECRET") + broker._trading_client = client + + await broker._sync_positions() + + assert broker.positions["AAPL"].quantity == -50.0 + assert broker.get_position("AAPL").quantity == -50.0 + + @patch("ml4t.live.brokers.alpaca.TradingClient") + @patch("ml4t.live.brokers.alpaca.TradingStream") + async def test_connected_short_is_closed_with_a_buy_to_cover( + self, mock_stream_class, mock_client_class + ): + client = mock_client_class.return_value + client.get_account.return_value = MockAlpacaAccount() + client.get_orders.return_value = [] + client.get_all_positions.return_value = [ + AlpacaPosition.model_validate( + { + "asset_id": "00000000-0000-0000-0000-000000000001", + "symbol": "AAPL", + "exchange": "NASDAQ", + "asset_class": "us_equity", + "avg_entry_price": "160", + "qty": "50", + "side": "short", + "cost_basis": "8000", + "current_price": "155", + } + ) + ] + client.submit_order.return_value = MockAlpacaOrder(qty="50") + broker = AlpacaBroker(api_key="PKTEST", secret_key="TEST") + await broker.connect() + try: + assert (await broker.get_position_async("AAPL")).quantity == -50 + order = await broker.close_position_async("AAPL") + + assert order.side is OrderSide.BUY + assert order.quantity == 50 + request = client.submit_order.call_args.args[0] + assert request.side.value == "buy" + assert request.qty == 50 + finally: + await broker.disconnect() + @pytest.mark.asyncio @patch("ml4t.live.brokers.alpaca.TradingClient") @patch("ml4t.live.brokers.alpaca.TradingStream") @@ -1512,6 +1570,77 @@ async def test_trade_update_faults_poison_the_snapshot( assert message in str(broker._snapshot_error) +@pytest.mark.asyncio +async def test_fill_event_does_not_invent_a_quantity(): + """A fill with no reported size must not be rewritten into a full fill.""" + broker = tracked_order_broker() + vendor_order = MockAlpacaOrder(id="venue-1", filled_qty="0") + vendor_order.filled_avg_price = "10" + + await broker._on_trade_update(MockTradeUpdate("fill", vendor_order)) + + assert broker.is_connected is False + assert "incomplete or invalid" in str(broker._snapshot_error) + assert broker._pending_orders["ML4T-1"].status is OrderStatus.PENDING + assert broker._pending_orders["ML4T-1"].filled_quantity == 0 + + +@pytest.mark.parametrize("quantity", ["0", None, "100"]) +@patch("ml4t.live.brokers.alpaca.TradingClient") +@patch("ml4t.live.brokers.alpaca.TradingStream") +async def test_registered_sdk_fill_preserves_order_when_quantity_is_missing( + mock_stream_class, mock_client_class, quantity +): + client = mock_client_class.return_value + client.get_account.return_value = MockAlpacaAccount() + client.get_all_positions.return_value = [] + client.get_orders.return_value = [] + now = datetime.now(UTC) + venue_order = AlpacaOrder.model_validate( + { + **vars(MockAlpacaOrder(id="00000000-0000-0000-0000-000000000001")), + "client_order_id": "fill-regression", + "updated_at": now, + "submitted_at": now, + "order_class": "simple", + "extended_hours": False, + } + ) + client.submit_order.return_value = venue_order + broker = AlpacaBroker(api_key="PKTEST", secret_key="TEST") + await broker.connect() + try: + order = await broker.submit_order_async("AAPL", 100, OrderSide.BUY) + original_fill = (order.status, order.filled_quantity, order.filled_price, order.filled_at) + callback = mock_stream_class.return_value.subscribe_trade_updates.call_args.args[0] + filled = AlpacaOrder.model_validate( + venue_order.model_dump() + | {"status": "filled", "filled_qty": quantity, "filled_avg_price": "10"} + ) + snapshots_before = client.get_all_positions.call_count + await callback(AlpacaTradeUpdate(event="fill", order=filled, timestamp=now)) + + if quantity == "100": + assert order.status is OrderStatus.FILLED + assert order.filled_quantity == 100 + assert broker.pending_orders == [] + assert broker.is_connected is True + assert client.get_all_positions.call_count == snapshots_before + 1 + else: + assert ( + order.status, + order.filled_quantity, + order.filled_price, + order.filled_at, + ) == original_fill + assert broker.is_connected is False + assert client.get_all_positions.call_count == snapshots_before + with pytest.raises(RuntimeError, match="unavailable"): + _ = broker.positions + finally: + await broker.disconnect() + + @pytest.mark.asyncio @pytest.mark.parametrize("event", ["replaced", "done_for_day"]) async def test_terminal_trade_updates_remove_all_tracking(event): @@ -1520,3 +1649,61 @@ async def test_terminal_trade_updates_remove_all_tracking(event): assert broker.pending_orders == [] assert broker._alpaca_order_map == {} + + +@patch("ml4t.live.brokers.alpaca.TradingClient") +@patch("ml4t.live.brokers.alpaca.TradingStream") +async def test_disconnect_stops_the_stream_after_a_provider_order_fault( + mock_stream_class, mock_client_class +): + client = mock_client_class.return_value + client.get_account.return_value = MockAlpacaAccount() + client.get_all_positions.return_value = [] + client.get_orders.return_value = [] + now = datetime.now(UTC) + venue_order = AlpacaOrder.model_validate( + { + **vars(MockAlpacaOrder(id="00000000-0000-0000-0000-000000000001")), + "client_order_id": "disconnect-regression", + "updated_at": now, + "submitted_at": now, + "order_class": "simple", + "extended_hours": False, + } + ) + client.submit_order.return_value = venue_order + started = threading.Event() + stopped = threading.Event() + stream = mock_stream_class.return_value + + def run_stream(): + started.set() + stopped.wait(5) + + stream.run.side_effect = run_stream + stream.stop.side_effect = stopped.set + broker = AlpacaBroker(api_key="PKTEST", secret_key="TEST") + await broker.connect() + try: + assert await asyncio.to_thread(started.wait, 2) + await broker.submit_order_async("AAPL", 100, OrderSide.BUY) + callback = stream.subscribe_trade_updates.call_args.args[0] + invalid_fill = AlpacaOrder.model_validate( + venue_order.model_dump() + | {"status": "filled", "filled_qty": "101", "filled_avg_price": "10"} + ) + await callback(AlpacaTradeUpdate(event="fill", order=invalid_fill, timestamp=now)) + assert broker.is_connected is False + + stream_task = broker._stream_task + await broker.disconnect() + + stream.stop.assert_called_once() + assert stopped.is_set() + assert stream_task.done() + await broker.disconnect() + stream.stop.assert_called_once() + finally: + stopped.set() + if broker._stream_task is not None: + await asyncio.wait_for(broker._stream_task, timeout=2) diff --git a/tests/unit/test_bar_aggregator.py b/tests/unit/test_bar_aggregator.py index 2c6ade4..9529cff 100644 --- a/tests/unit/test_bar_aggregator.py +++ b/tests/unit/test_bar_aggregator.py @@ -102,6 +102,53 @@ async def test_truncates_to_configured_boundary(self) -> None: assert aggregator._truncate_to_bar(timestamp) == datetime(2024, 1, 1, 10, 35, tzinfo=UTC) + async def test_bar_windows_do_not_restart_at_the_hour(self) -> None: + """Sizes that do not divide 60 minutes must stay aligned across the hour.""" + start = datetime(2024, 1, 1, 10, 30, tzinfo=UTC) + events = await collect( + BarAggregator( + MockDataFeed( + [ + trade("AAPL", start, 100.0, 1), + trade("AAPL", start + timedelta(minutes=59), 110.0, 1), + trade("AAPL", start + timedelta(minutes=90), 120.0, 1), + ] + ), + bar_size_minutes=90, + ) + ) + + assert [event.event_time for event in events] == [ + start, + start + timedelta(minutes=90), + ] + assert events[0].payload == BarPayload(100.0, 110.0, 100.0, 110.0, 2.0) + assert events[1].payload == BarPayload(120.0, 120.0, 120.0, 120.0, 1.0) + + async def test_bar_windows_do_not_overlap_across_midnight(self) -> None: + start = datetime(2024, 1, 1, 23, tzinfo=UTC) + events = await collect( + BarAggregator( + MockDataFeed( + [ + trade("AAPL", start + timedelta(minutes=50), 100.0, 1), + trade("AAPL", start + timedelta(minutes=80), 110.0, 1), + trade("AAPL", start + timedelta(minutes=100), 120.0, 1), + ] + ), + bar_size_minutes=100, + ) + ) + + assert datetime.fromisoformat(events[0].metadata["bar_end"]) <= events[1].event_time + assert [event.event_time for event in events] == [ + start, + start + timedelta(minutes=100), + ] + assert events[0].metadata["bar_end"] == (start + timedelta(minutes=100)).isoformat() + assert events[0].payload == BarPayload(100.0, 110.0, 100.0, 110.0, 2.0) + assert events[1].payload == BarPayload(120.0, 120.0, 120.0, 120.0, 1.0) + async def test_boundary_and_shutdown_emit_exact_completed_bars(self) -> None: start = datetime(2024, 1, 1, 10, tzinfo=UTC) events = await collect( diff --git a/tests/unit/test_provider_contract.py b/tests/unit/test_provider_contract.py index cf9868e..4676aec 100644 --- a/tests/unit/test_provider_contract.py +++ b/tests/unit/test_provider_contract.py @@ -226,7 +226,17 @@ def test_tampered_reported_contract_fails_closed( assert classification.reason == "reported contract does not match its evidence commit" -def test_ib_tif_change_invalidates_v012_evidence() -> None: +@pytest.mark.parametrize( + ("provider", "changed_input"), + [ + ("ib", "src/ml4t/live/brokers/ib.py"), + ("ib", "src/ml4t/live/safety.py"), + ("alpaca", "src/ml4t/live/safety.py"), + ], +) +def test_changed_broker_inputs_require_fresh_provider_evidence( + provider: str, changed_input: str +) -> None: repository = Path(__file__).resolve().parents[2] tag = subprocess.run( ["git", "cat-file", "-e", "v0.1.2^{commit}"], @@ -236,7 +246,7 @@ def test_ib_tif_change_invalidates_v012_evidence() -> None: ) if tag.returncode != 0: pytest.skip("v0.1.2 history is required") - classification = _classification(repository, "ib", "v0.1.2", "HEAD") + classification = _classification(repository, provider, "v0.1.2", "HEAD") assert classification.reusable is False - assert "src/ml4t/live/brokers/ib.py" in classification.changed_inputs + assert changed_input in classification.changed_inputs diff --git a/tests/unit/test_safe_broker.py b/tests/unit/test_safe_broker.py index 8ee3ed0..64b95e5 100644 --- a/tests/unit/test_safe_broker.py +++ b/tests/unit/test_safe_broker.py @@ -188,6 +188,46 @@ async def test_get_account_value_shadow_mode(mock_broker, config): assert value == safe._virtual_portfolio.account_value +async def test_position_lookup_and_close_ignore_symbol_case(mock_broker, config): + """Venue adapters key positions in uppercase; the safety facade must too.""" + config.shadow_mode = True + config.execution_mode = "shadow" + safe = SafeBroker(mock_broker, config) + safe._virtual_portfolio._positions["AAPL"] = Position( + asset="AAPL", + quantity=10, + entry_price=150.0, + entry_time=datetime.now(UTC), + current_price=150.0, + ) + + assert safe.get_position("aapl") is not None + assert (await safe.get_position_async("aapl")).quantity == 10 + + order = await safe.close_position_async("aapl") + + assert order is not None + assert order.side == OrderSide.SELL + assert order.quantity == 10 + assert "AAPL" not in safe.positions + mock_broker.submit_order_async.assert_not_called() + + +def test_external_position_lookup_ignores_symbol_case(mock_broker, config): + """Paper and live lookups use the same uppercase position keys as the adapters.""" + mock_broker.positions = { + "AAPL": Position( + asset="AAPL", + quantity=10, + entry_price=150.0, + entry_time=datetime.now(UTC), + ) + } + safe = SafeBroker(mock_broker, config) + + assert safe.get_position("aapl").quantity == 10 + + async def test_get_cash_shadow_mode(mock_broker, config): """Test get_cash_async returns virtual cash in shadow mode.""" config.shadow_mode = True @@ -786,6 +826,29 @@ def test_prune_history_removes_old_recent_orders(mock_broker, config): # === Submit Order Integration Tests === +async def test_shadow_order_ids_stay_unique_when_the_clock_does_not_advance( + mock_broker, config, monkeypatch +): + """Millisecond shadow ids collide when two orders are accepted in the same tick.""" + config.shadow_mode = True + config.execution_mode = "shadow" + config.dedup_window_seconds = 0 + safe = SafeBroker(mock_broker, config) + monkeypatch.setattr("ml4t.live.safety.time.time", lambda: 1_700_000_000.0) + safe._record_market_data( + datetime.now(UTC), + {"AAPL": {"close": 150.0}, "MSFT": {"close": 300.0}}, + {}, + ) + + first = await safe.submit_order_async("AAPL", 1, OrderSide.BUY) + second = await safe.submit_order_async("MSFT", 1, OrderSide.BUY) + + assert first.order_id != second.order_id + assert first.order_id.startswith("SHADOW-") + assert second.order_id.startswith("SHADOW-") + + async def test_submit_order_shadow_mode(mock_broker, config): """Test submit_order_async in shadow mode.""" config.shadow_mode = True diff --git a/tests/unit/test_wrappers.py b/tests/unit/test_wrappers.py index e13a441..30e13c2 100644 --- a/tests/unit/test_wrappers.py +++ b/tests/unit/test_wrappers.py @@ -269,6 +269,7 @@ async def test_get_position(): assert pos is not None assert pos.asset == "AAPL" assert pos.quantity == 100 + assert wrapper.get_position("aapl").quantity == 100 assert wrapper.get_position("TSLA") is None