From f116fb6ea8f6fc6bc0548b6530053cd2d642aef5 Mon Sep 17 00:00:00 2001 From: Al4ise Date: Mon, 13 Jul 2026 00:16:20 +0300 Subject: [PATCH 01/11] Gate changes with paper broker integration tests --- .github/workflows/cicd.yaml | 34 ++++++++++- tests/test_live_broker_gate.py | 107 +++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 tests/test_live_broker_gate.py diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index 8f2a8b68a..fc517a423 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -9,7 +9,7 @@ on: - "v*.*.*" - "ci-*" pull_request: - branches: [main] + branches: [main, dev] paths: - "lumibot/**" - "tests/**" @@ -267,17 +267,44 @@ jobs: echo "Running $(wc -l shard_nodeids.txt | awk '{print $1}') nodeids" timeout 1500 python -m pytest -m "${PYTEST_MARKERS}" --tb=short -q --durations=30 -x $(cat shard_nodeids.txt) + live-broker-gate: + name: Live Broker Gate (Alpaca + Tradier paper) + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: unit-tests + needs: lint + steps: + - uses: actions/checkout@v3 + + - name: Set up Python 3.10 + uses: actions/setup-python@v4 + with: + python-version: "3.10" + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install requests + pip install -r requirements_dev.txt + + - name: Exercise real paper broker boundaries + run: | + set -euo pipefail + timeout 600 python -m pytest tests/test_live_broker_gate.py -m apitest --tb=short -q + LintAndTest: name: LintAndTest runs-on: ubuntu-latest if: always() - needs: [lint, unit-tests, backtest-tests] + needs: [lint, unit-tests, backtest-tests, live-broker-gate] steps: - name: Check results run: | echo "lint: ${{ needs.lint.result }}" echo "unit-tests: ${{ needs.unit-tests.result }}" echo "backtest-tests: ${{ needs.backtest-tests.result }}" + echo "live-broker-gate: ${{ needs.live-broker-gate.result }}" if [ "${{ needs.lint.result }}" != "success" ]; then exit 1 @@ -288,3 +315,6 @@ jobs: if [ "${{ needs.backtest-tests.result }}" != "success" ]; then exit 1 fi + if [ "${{ needs.live-broker-gate.result }}" != "success" ]; then + exit 1 + fi diff --git a/tests/test_live_broker_gate.py b/tests/test_live_broker_gate.py new file mode 100644 index 000000000..44b9a56fe --- /dev/null +++ b/tests/test_live_broker_gate.py @@ -0,0 +1,107 @@ +"""Small real-broker gate for changes that can affect live trading startup.""" + +import time +from math import isfinite +from types import SimpleNamespace + +import pytest + +from lumibot.brokers.alpaca import Alpaca +from lumibot.brokers.tradier import Tradier +from lumibot.credentials import ALPACA_TEST_CONFIG, TRADIER_TEST_CONFIG +from lumibot.entities import Asset, Order + +pytestmark = pytest.mark.apitest + + +def _required(value, name: str): + assert value and value != "", f"{name} is required for the live broker gate" + return value + + +def _assert_account_reads(broker, strategy) -> None: + cash, positions_value, total_value = broker._get_balances_at_broker( + Asset("USD", asset_type=Asset.AssetType.FOREX), + strategy, + ) + assert all(isfinite(float(value)) for value in (cash, positions_value, total_value)) + assert float(total_value) >= 0 + assert isinstance(broker._pull_positions(strategy), list) + assert isinstance(broker._pull_broker_all_orders(), list) + + +def _non_marketable_limit_order(strategy_name: str, price: float) -> Order: + assert isfinite(float(price)) and float(price) > 0 + return Order( + strategy=strategy_name, + asset=Asset("AAPL"), + quantity=1, + side=Order.OrderSide.BUY, + order_type=Order.OrderType.LIMIT, + limit_price=max(0.01, round(float(price) * 0.1, 2)), + time_in_force="day", + ) + + +def _assert_submit_read_cancel(broker, strategy) -> None: + price = broker.get_last_price(Asset("AAPL")) + order = _non_marketable_limit_order(strategy.name, price) + submitted = broker._submit_order(order) + assert submitted is not None + assert submitted.identifier + + try: + assert broker._pull_broker_order(submitted.identifier) is not None + all_orders = broker._pull_broker_all_orders() + assert any(str(row.get("id")) == str(submitted.identifier) for row in all_orders) if ( + all_orders and isinstance(all_orders[0], dict) + ) else any(str(getattr(row, "id", "")) == str(submitted.identifier) for row in all_orders) + finally: + broker.cancel_order(submitted) + + for _ in range(15): + current = broker._pull_broker_order(submitted.identifier) + raw_status = current.get("status") if isinstance(current, dict) else getattr(current, "status", None) + status = str(getattr(raw_status, "value", raw_status)).lower() + if status in {"cancelled", "canceled"}: + break + time.sleep(1) + else: + pytest.fail(f"broker did not confirm cancellation; final status={status!r}") + + +def test_alpaca_paper_account_positions_orders_and_cancel() -> None: + config = dict(ALPACA_TEST_CONFIG) + _required(config.get("API_KEY"), "ALPACA_TEST_API_KEY") + _required(config.get("API_SECRET"), "ALPACA_TEST_API_SECRET") + assert config.get("PAPER") is True, "Alpaca live-broker gate must use paper trading" + + broker = Alpaca( + config, + connect_stream=False, + start_orders_thread=False, + ) + strategy = SimpleNamespace(name="ci-alpaca-paper-gate") + _assert_account_reads(broker, strategy) + _assert_submit_read_cancel(broker, strategy) + + +def test_tradier_paper_account_positions_orders_and_cancel() -> None: + account_number = _required( + TRADIER_TEST_CONFIG.get("ACCOUNT_NUMBER"), + "TRADIER_TEST_ACCOUNT_NUMBER", + ) + access_token = _required( + TRADIER_TEST_CONFIG.get("ACCESS_TOKEN"), + "TRADIER_TEST_ACCESS_TOKEN", + ) + + broker = Tradier( + account_number=account_number, + access_token=access_token, + paper=True, + connect_stream=False, + ) + strategy = SimpleNamespace(name="ci-tradier-paper-gate") + _assert_account_reads(broker, strategy) + _assert_submit_read_cancel(broker, strategy) From 94bb8cf9d283ce506dbb14dbbd4ddfd0d0233084 Mon Sep 17 00:00:00 2001 From: Al4ise Date: Mon, 13 Jul 2026 00:41:41 +0300 Subject: [PATCH 02/11] Exercise paper brokers through real strategy runs --- tests/test_live_broker_gate.py | 122 +++++++++++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 6 deletions(-) diff --git a/tests/test_live_broker_gate.py b/tests/test_live_broker_gate.py index 44b9a56fe..538ab7ad4 100644 --- a/tests/test_live_broker_gate.py +++ b/tests/test_live_broker_gate.py @@ -10,6 +10,8 @@ from lumibot.brokers.tradier import Tradier from lumibot.credentials import ALPACA_TEST_CONFIG, TRADIER_TEST_CONFIG from lumibot.entities import Asset, Order +from lumibot.strategies.strategy import Strategy +from lumibot.traders.trader import Trader pytestmark = pytest.mark.apitest @@ -70,6 +72,79 @@ def _assert_submit_read_cancel(broker, strategy) -> None: pytest.fail(f"broker did not confirm cancellation; final status={status!r}") +class _PaperSubmitCancelStrategy(Strategy): + """Run one real strategy iteration while keeping the paper order nonmarketable.""" + + def initialize(self, parameters=None): + self.sleeptime = "1S" + self.account_reads_completed = False + self.iteration_ran = False + self.submitted_order = None + self.cancelled = False + + def before_starting_trading(self): + self.account_reads_completed = self.get_cash() is not None and self.get_positions() is not None + + def on_trading_iteration(self): + order = self.create_order( + Asset("AAPL"), + 1, + Order.OrderSide.BUY, + order_type=Order.OrderType.LIMIT, + limit_price=0.01, + time_in_force="gtc", + ) + self.submitted_order = self.submit_order(order) + self.iteration_ran = True + + def on_strategy_end(self): + if self.submitted_order is not None: + self.broker.cancel_order(self.submitted_order) + + +def _assert_strategy_run_submit_and_cancel(broker, name: str) -> None: + # The paper APIs still provide the real market clock, which is exercised before + # the override. The override only makes this order lifecycle test deterministic + # overnight and on weekends. + assert isinstance(broker.is_market_open(), bool) + broker.is_market_open = lambda: True + + strategy = _PaperSubmitCancelStrategy( + broker=broker, + name=name, + benchmark_asset=None, + analyze_backtest=False, + should_backup_variables_to_database=False, + should_send_summary_to_discord=False, + ) + strategy._executor._initialize_live_market_calendars_for_run_once = ( + lambda: setattr(strategy._executor, "_run_once_market_open_override", True) + ) + trader = Trader(logfile="", backtest=False) + trader.add_strategy(strategy) + + try: + result = trader.run_all(run_once=True) + assert result is not None + assert strategy.account_reads_completed + assert strategy.iteration_ran + assert strategy.submitted_order is not None + assert strategy.submitted_order.identifier + + for _ in range(30): + current = broker._pull_broker_order(strategy.submitted_order.identifier) + raw_status = current.get("status") if isinstance(current, dict) else getattr(current, "status", None) + status = str(getattr(raw_status, "value", raw_status)).lower() + if status in {"cancelled", "canceled"}: + strategy.cancelled = True + break + time.sleep(1) + assert strategy.cancelled, f"strategy order was not cancelled; final status={status!r}" + finally: + if strategy.submitted_order is not None and not strategy.cancelled: + broker.cancel_order(strategy.submitted_order) + + def test_alpaca_paper_account_positions_orders_and_cancel() -> None: config = dict(ALPACA_TEST_CONFIG) _required(config.get("API_KEY"), "ALPACA_TEST_API_KEY") @@ -81,9 +156,12 @@ def test_alpaca_paper_account_positions_orders_and_cancel() -> None: connect_stream=False, start_orders_thread=False, ) - strategy = SimpleNamespace(name="ci-alpaca-paper-gate") - _assert_account_reads(broker, strategy) - _assert_submit_read_cancel(broker, strategy) + try: + strategy = SimpleNamespace(name="ci-alpaca-paper-gate") + _assert_account_reads(broker, strategy) + _assert_submit_read_cancel(broker, strategy) + finally: + broker.cleanup_streams() def test_tradier_paper_account_positions_orders_and_cancel() -> None: @@ -102,6 +180,38 @@ def test_tradier_paper_account_positions_orders_and_cancel() -> None: paper=True, connect_stream=False, ) - strategy = SimpleNamespace(name="ci-tradier-paper-gate") - _assert_account_reads(broker, strategy) - _assert_submit_read_cancel(broker, strategy) + try: + strategy = SimpleNamespace(name="ci-tradier-paper-gate") + _assert_account_reads(broker, strategy) + _assert_submit_read_cancel(broker, strategy) + finally: + broker.cleanup_streams() + + +def test_alpaca_paper_strategy_run_submits_and_cancels() -> None: + config = dict(ALPACA_TEST_CONFIG) + _required(config.get("API_KEY"), "ALPACA_TEST_API_KEY") + _required(config.get("API_SECRET"), "ALPACA_TEST_API_SECRET") + assert config.get("PAPER") is True, "Alpaca live-broker gate must use paper trading" + + broker = Alpaca(config, connect_stream=False, start_orders_thread=False) + try: + _assert_strategy_run_submit_and_cancel(broker, "ci-alpaca-paper-strategy-gate") + finally: + broker.cleanup_streams() + + +def test_tradier_paper_strategy_run_submits_and_cancels() -> None: + account_number = _required(TRADIER_TEST_CONFIG.get("ACCOUNT_NUMBER"), "TRADIER_TEST_ACCOUNT_NUMBER") + access_token = _required(TRADIER_TEST_CONFIG.get("ACCESS_TOKEN"), "TRADIER_TEST_ACCESS_TOKEN") + + broker = Tradier( + account_number=account_number, + access_token=access_token, + paper=True, + connect_stream=False, + ) + try: + _assert_strategy_run_submit_and_cancel(broker, "ci-tradier-paper-strategy-gate") + finally: + broker.cleanup_streams() From 9f59f3036207f6c63b28e2a07d670f8d2d8d5d63 Mon Sep 17 00:00:00 2001 From: Al4ise Date: Mon, 13 Jul 2026 00:43:43 +0300 Subject: [PATCH 03/11] Make paper broker gate fail closed --- .github/workflows/cicd.yaml | 2 +- tests/test_live_broker_gate.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index fc517a423..fc456f1e0 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -291,7 +291,7 @@ jobs: - name: Exercise real paper broker boundaries run: | set -euo pipefail - timeout 600 python -m pytest tests/test_live_broker_gate.py -m apitest --tb=short -q + timeout 600 python -m tests.test_live_broker_gate LintAndTest: name: LintAndTest diff --git a/tests/test_live_broker_gate.py b/tests/test_live_broker_gate.py index 538ab7ad4..215dbd49a 100644 --- a/tests/test_live_broker_gate.py +++ b/tests/test_live_broker_gate.py @@ -215,3 +215,15 @@ def test_tradier_paper_strategy_run_submits_and_cancels() -> None: _assert_strategy_run_submit_and_cancel(broker, "ci-tradier-paper-strategy-gate") finally: broker.cleanup_streams() + + +def run_live_broker_gate() -> None: + """Run without pytest's unrelated legacy Polygon/Theta credential gate.""" + test_alpaca_paper_account_positions_orders_and_cancel() + test_tradier_paper_account_positions_orders_and_cancel() + test_alpaca_paper_strategy_run_submits_and_cancels() + test_tradier_paper_strategy_run_submits_and_cancels() + + +if __name__ == "__main__": + run_live_broker_gate() From a233d757e7f63bdbe02585ba6c802a1c7b9ac515 Mon Sep 17 00:00:00 2001 From: Al4ise Date: Mon, 13 Jul 2026 00:45:10 +0300 Subject: [PATCH 04/11] Serialize shared paper broker checks --- .github/workflows/cicd.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index fc456f1e0..60e3b7272 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -273,6 +273,9 @@ jobs: timeout-minutes: 15 environment: unit-tests needs: lint + concurrency: + group: lumibot-paper-broker-gate-${{ github.repository }} + cancel-in-progress: false steps: - uses: actions/checkout@v3 From 6b4d2012e09c9fea08f1e1ee1fb32f498007832b Mon Sep 17 00:00:00 2001 From: Al4ise Date: Mon, 13 Jul 2026 00:45:41 +0300 Subject: [PATCH 05/11] Harden paper broker workflow setup --- .github/workflows/cicd.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index 60e3b7272..e7a78f7d4 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -277,10 +277,12 @@ jobs: group: lumibot-paper-broker-gate-${{ github.repository }} cancel-in-progress: false steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.10" cache: pip From a6a8458efe177561713e230376764cd01b5319b8 Mon Sep 17 00:00:00 2001 From: Al4ise Date: Mon, 13 Jul 2026 00:56:50 +0300 Subject: [PATCH 06/11] Use current GitHub action runtimes --- .github/workflows/cicd.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index e7a78f7d4..5c2da90b4 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -277,12 +277,12 @@ jobs: group: lumibot-paper-broker-gate-${{ github.repository }} cancel-in-progress: false steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false - name: Set up Python 3.10 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.10" cache: pip From bf22bf0bfc4a9b3cc312907632386601e593c2ed Mon Sep 17 00:00:00 2001 From: Al4ise Date: Mon, 13 Jul 2026 01:20:52 +0300 Subject: [PATCH 07/11] Update CI action runtimes --- .github/workflows/cicd.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index 5c2da90b4..187faf86f 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -54,10 +54,10 @@ jobs: timeout-minutes: 15 environment: unit-tests steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: "3.10" cache: pip @@ -99,10 +99,10 @@ jobs: shard: [0, 1, 2, 3, 4, 5] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: "3.10" cache: pip @@ -189,10 +189,10 @@ jobs: shard: [0, 1, 2, 3] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: "3.10" cache: pip From 33eeae6b745b19a6b88adc02ed462a2af068b58c Mon Sep 17 00:00:00 2001 From: Al4ise Date: Mon, 13 Jul 2026 12:25:14 +0300 Subject: [PATCH 08/11] test: refresh smart-limit acceptance baseline --- .../backtest/acceptance_backtests_baselines.json | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/backtest/acceptance_backtests_baselines.json b/tests/backtest/acceptance_backtests_baselines.json index 08717c028..7188a70ef 100644 --- a/tests/backtest/acceptance_backtests_baselines.json +++ b/tests/backtest/acceptance_backtests_baselines.json @@ -124,20 +124,21 @@ }, { "backtest_time_seconds": 641.7320420742035, - "baseline_run_id": "BackdoorButterfly0DTESmartLimit_2026-02-11_07-30_cj3SET", + "baseline_run_id": "BackdoorButterfly0DTESmartLimit_2026-07-13_09-09_dqdCoT", "data_source": "thetadata", "end_date": "2025-12-01", "lumibot_version": "4.4.50", "max_backtest_time_seconds": 900, + "_rebaseline_note": "2026-07-13 ThetaData/cache refresh. Two independent full CI runs (paper-broker gate branch and market-clock/lazy-export branch) produced identical metrics: total return -12.00%, CAGR -13.07%, max drawdown -24.22%. Rebaseline the deterministic repeated output without widening the 500-centipercent tolerance.", "metrics_centipercent": { - "cagr": -1947, - "max_drawdown": -2499, - "total_return": -1782 + "cagr": -1307, + "max_drawdown": -2422, + "total_return": -1200 }, "metrics_raw": { - "cagr": "-19.47%", - "max_drawdown": "-24.99%", - "total_return": "-17.82%" + "cagr": "-13.07%", + "max_drawdown": "-24.22%", + "total_return": "-12.00%" }, "script_filename": "Backdoor Butterfly 0 DTE (Copy) - with SMART LIMITS.py", "settings_backtesting_end": "2025-11-30 23:59:00-05:00", From 0a40459e43d5d380e40f1fef132de6cad32f0dda Mon Sep 17 00:00:00 2001 From: Al4ise Date: Mon, 13 Jul 2026 12:33:54 +0300 Subject: [PATCH 09/11] ci: stop persisting checkout credentials --- .github/workflows/cicd.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index 187faf86f..a08bbca7d 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -55,6 +55,8 @@ jobs: environment: unit-tests steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Python 3.10 uses: actions/setup-python@v6 @@ -100,6 +102,8 @@ jobs: steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Python 3.10 uses: actions/setup-python@v6 @@ -190,6 +194,8 @@ jobs: steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Python 3.10 uses: actions/setup-python@v6 From 21a685ed854eec080442ef4436ef3bcc4b0c317c Mon Sep 17 00:00:00 2001 From: Al4ise Date: Mon, 13 Jul 2026 14:08:37 +0300 Subject: [PATCH 10/11] test: consolidate paper broker CI coverage --- docs/PAPER_BROKER_CI.md | 63 +++++++++++++++++++++ docsrc/environment_variables.rst | 8 +++ tests/test_live_broker_gate.py | 97 +++++++++++++++++++++++++++++--- 3 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 docs/PAPER_BROKER_CI.md diff --git a/docs/PAPER_BROKER_CI.md b/docs/PAPER_BROKER_CI.md new file mode 100644 index 000000000..88e87d946 --- /dev/null +++ b/docs/PAPER_BROKER_CI.md @@ -0,0 +1,63 @@ +# Paper Broker CI Gate + +One-line description: Required Alpaca and Tradier paper-account coverage for live broker boundaries. + +Last Updated: 2026-07-13 + +Status: Active + +Audience: Developers, AI Agents + +## Overview + +The `Live Broker Gate (Alpaca + Tradier paper)` job in `.github/workflows/cicd.yaml` is part of the primary CI workflow. It fails closed when credentials or real broker behavior are unavailable, and the aggregate `LintAndTest` job cannot pass without it. + +The job uses repository environment secrets, runs only against paper or sandbox accounts, and serializes all runs in one repository-wide concurrency group. Fork pull requests do not receive the secrets and therefore cannot exercise the shared accounts. + +## Credentials + +The `unit-tests` GitHub environment must provide: + +- `ALPACA_TEST_API_KEY` +- `ALPACA_TEST_API_SECRET` +- `TRADIER_TEST_ACCOUNT_NUMBER` +- `TRADIER_TEST_ACCESS_TOKEN` + +Never commit real credential values. No production broker credentials are used by this gate. + +## Coverage + +`tests/test_live_broker_gate.py` performs four real paper-broker checks: + +- Alpaca account, position, and order reads plus submit/read/cancel of one non-marketable AAPL limit order. +- Tradier account, position, and order reads plus the same paper-only order lifecycle. +- A one-iteration Alpaca `Strategy` lifecycle that reads AAPL quotes and daily bars, reads SPY call and put chains, resolves a valid option contract, submits through the public strategy API, and cancels during strategy shutdown. +- A one-iteration Tradier `Strategy` lifecycle that reads account state, submits through the public strategy API, and cancels during strategy shutdown. + +The market clock is read from each real broker before the strategy tests apply a local run-once override. This keeps weekend and overnight CI deterministic without bypassing broker authentication, data calls, order submission, order reads, cancellation, or cleanup. + +The ordinary unit-test shards continue to cover local agent runtime, MCP transport, permission, provider-key, and built-in Alpaca news behavior. Those deterministic tests are not duplicated in the real-account job. + +## Safety + +- Both broker configurations are asserted to use paper or sandbox mode. +- Orders are one-share AAPL buy limits priced far below the market. +- Every submitted order is cancelled in normal and cleanup paths. +- The job does not start broker streams or background order threads. +- Shared paper accounts are protected by non-cancelling repository-wide concurrency. +- Missing credentials and incomplete broker behavior fail the gate instead of skipping it. + +## Local Run + +Use dedicated paper credentials only: + +```bash +export ALPACA_TEST_API_KEY="..." +export ALPACA_TEST_API_SECRET="..." +export TRADIER_TEST_ACCOUNT_NUMBER="..." +export TRADIER_TEST_ACCESS_TOKEN="..." + +python -m tests.test_live_broker_gate +``` + +The direct module invocation is intentional: it runs only this fail-closed gate and avoids unrelated legacy `apitest` credential requirements. diff --git a/docsrc/environment_variables.rst b/docsrc/environment_variables.rst index 4a97e6f62..0a65014c7 100644 --- a/docsrc/environment_variables.rst +++ b/docsrc/environment_variables.rst @@ -94,6 +94,14 @@ BACKTESTING_DATA_SOURCE Testing / CI guardrails ----------------------- +Paper broker CI credentials +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- ``ALPACA_TEST_API_KEY`` / ``ALPACA_TEST_API_SECRET``: dedicated Alpaca paper-account credentials used by the required live broker CI gate. +- ``TRADIER_TEST_ACCOUNT_NUMBER`` / ``TRADIER_TEST_ACCESS_TOKEN``: dedicated Tradier sandbox-account credentials used by the same gate. +- These variables must contain paper or sandbox credentials only. Missing values fail the gate; never commit their values. +- See ``docs/PAPER_BROKER_CI.md`` for the tested boundaries and local command. + LUMIBOT_ACCEPTANCE_TRIPWIRE ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/test_live_broker_gate.py b/tests/test_live_broker_gate.py index 215dbd49a..630b69b3c 100644 --- a/tests/test_live_broker_gate.py +++ b/tests/test_live_broker_gate.py @@ -1,6 +1,7 @@ """Small real-broker gate for changes that can affect live trading startup.""" import time +from datetime import datetime, timedelta from math import isfinite from types import SimpleNamespace @@ -8,6 +9,7 @@ from lumibot.brokers.alpaca import Alpaca from lumibot.brokers.tradier import Tradier +from lumibot.components.options_helper import OptionsHelper from lumibot.credentials import ALPACA_TEST_CONFIG, TRADIER_TEST_CONFIG from lumibot.entities import Asset, Order from lumibot.strategies.strategy import Strategy @@ -55,9 +57,11 @@ def _assert_submit_read_cancel(broker, strategy) -> None: try: assert broker._pull_broker_order(submitted.identifier) is not None all_orders = broker._pull_broker_all_orders() - assert any(str(row.get("id")) == str(submitted.identifier) for row in all_orders) if ( - all_orders and isinstance(all_orders[0], dict) - ) else any(str(getattr(row, "id", "")) == str(submitted.identifier) for row in all_orders) + assert ( + any(str(row.get("id")) == str(submitted.identifier) for row in all_orders) + if (all_orders and isinstance(all_orders[0], dict)) + else any(str(getattr(row, "id", "")) == str(submitted.identifier) for row in all_orders) + ) finally: broker.cancel_order(submitted) @@ -102,14 +106,80 @@ def on_strategy_end(self): self.broker.cancel_order(self.submitted_order) -def _assert_strategy_run_submit_and_cancel(broker, name: str) -> None: +class _AlpacaPaperSubmitCancelStrategy(_PaperSubmitCancelStrategy): + """Keep the consolidated gate's Alpaca data and option-chain regression coverage.""" + + def initialize(self, parameters=None): + super().initialize(parameters) + self.options_helper = OptionsHelper(self) + self.stock_price = None + self.bar_count = 0 + self.call_expirations = 0 + self.put_expirations = 0 + self.option_symbol = None + + def on_trading_iteration(self): + stock = Asset("AAPL", asset_type=Asset.AssetType.STOCK) + stock_price = self.get_last_price(stock) + assert stock_price is not None and float(stock_price) > 0 + self.stock_price = float(stock_price) + + bars = self.get_historical_prices(stock, 3, "day") + assert bars is not None and bars.df is not None and not bars.df.empty + self.bar_count = len(bars.df) + + underlying = Asset("SPY", asset_type=Asset.AssetType.STOCK) + underlying_price = self.get_last_price(underlying) + assert underlying_price is not None and float(underlying_price) > 0 + + chains = self.get_chains(underlying) + chain_root = chains.get("Chains", {}) if isinstance(chains, dict) else {} + call_chains = chain_root.get("CALL", {}) + put_chains = chain_root.get("PUT", {}) + assert call_chains, "Alpaca returned no SPY call chains" + assert put_chains, "Alpaca returned no SPY put chains" + self.call_expirations = len(call_chains) + self.put_expirations = len(put_chains) + + target_date = datetime.now().astimezone().date() + timedelta(days=7) + expiry = self.options_helper.get_expiration_on_or_after_date( + target_date, + chains, + "call", + underlying_asset=underlying, + ) + assert expiry is not None + + expiry_key = expiry.strftime("%Y-%m-%d") + strikes = call_chains.get(expiry_key) + assert strikes, f"Alpaca returned no SPY call strikes for {expiry_key}" + strike = min(strikes, key=lambda value: abs(float(value) - float(underlying_price))) + + option = self.options_helper.find_next_valid_option( + underlying, + strike, + expiry, + put_or_call="call", + chains=chains, + ) + assert option is not None + self.option_symbol = str(option) + + super().on_trading_iteration() + + +def _assert_strategy_run_submit_and_cancel( + broker, + name: str, + strategy_class: type[Strategy] = _PaperSubmitCancelStrategy, +) -> Strategy: # The paper APIs still provide the real market clock, which is exercised before # the override. The override only makes this order lifecycle test deterministic # overnight and on weekends. assert isinstance(broker.is_market_open(), bool) broker.is_market_open = lambda: True - strategy = _PaperSubmitCancelStrategy( + strategy = strategy_class( broker=broker, name=name, benchmark_asset=None, @@ -117,8 +187,8 @@ def _assert_strategy_run_submit_and_cancel(broker, name: str) -> None: should_backup_variables_to_database=False, should_send_summary_to_discord=False, ) - strategy._executor._initialize_live_market_calendars_for_run_once = ( - lambda: setattr(strategy._executor, "_run_once_market_open_override", True) + strategy._executor._initialize_live_market_calendars_for_run_once = lambda: setattr( + strategy._executor, "_run_once_market_open_override", True ) trader = Trader(logfile="", backtest=False) trader.add_strategy(strategy) @@ -144,6 +214,8 @@ def _assert_strategy_run_submit_and_cancel(broker, name: str) -> None: if strategy.submitted_order is not None and not strategy.cancelled: broker.cancel_order(strategy.submitted_order) + return strategy + def test_alpaca_paper_account_positions_orders_and_cancel() -> None: config = dict(ALPACA_TEST_CONFIG) @@ -196,7 +268,16 @@ def test_alpaca_paper_strategy_run_submits_and_cancels() -> None: broker = Alpaca(config, connect_stream=False, start_orders_thread=False) try: - _assert_strategy_run_submit_and_cancel(broker, "ci-alpaca-paper-strategy-gate") + strategy = _assert_strategy_run_submit_and_cancel( + broker, + "ci-alpaca-paper-strategy-gate", + strategy_class=_AlpacaPaperSubmitCancelStrategy, + ) + assert strategy.stock_price > 0 + assert strategy.bar_count >= 1 + assert strategy.call_expirations > 0 + assert strategy.put_expirations > 0 + assert strategy.option_symbol finally: broker.cleanup_streams() From 460e72413b6c77e01fe219df95b4d7c14d0fff20 Mon Sep 17 00:00:00 2001 From: Al4ise Date: Mon, 13 Jul 2026 14:13:26 +0300 Subject: [PATCH 11/11] docs: clarify broker gate test intent --- tests/test_live_broker_gate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_live_broker_gate.py b/tests/test_live_broker_gate.py index 630b69b3c..080e56dd6 100644 --- a/tests/test_live_broker_gate.py +++ b/tests/test_live_broker_gate.py @@ -110,6 +110,7 @@ class _AlpacaPaperSubmitCancelStrategy(_PaperSubmitCancelStrategy): """Keep the consolidated gate's Alpaca data and option-chain regression coverage.""" def initialize(self, parameters=None): + """Prepare result fields used to prove the real Alpaca reads completed.""" super().initialize(parameters) self.options_helper = OptionsHelper(self) self.stock_price = None @@ -119,6 +120,7 @@ def initialize(self, parameters=None): self.option_symbol = None def on_trading_iteration(self): + """Exercise Alpaca market-data and option-chain paths before the paper order.""" stock = Asset("AAPL", asset_type=Asset.AssetType.STOCK) stock_price = self.get_last_price(stock) assert stock_price is not None and float(stock_price) > 0 @@ -173,6 +175,7 @@ def _assert_strategy_run_submit_and_cancel( name: str, strategy_class: type[Strategy] = _PaperSubmitCancelStrategy, ) -> Strategy: + """Run one real strategy iteration and require its paper order to be cancelled.""" # The paper APIs still provide the real market clock, which is exercised before # the override. The override only makes this order lifecycle test deterministic # overnight and on weekends.