diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index 8f2a8b68a..a08bbca7d 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/**" @@ -54,10 +54,12 @@ jobs: timeout-minutes: 15 environment: unit-tests steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 + with: + persist-credentials: false - 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 +101,12 @@ jobs: shard: [0, 1, 2, 3, 4, 5] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 + with: + persist-credentials: false - 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 +193,12 @@ jobs: shard: [0, 1, 2, 3] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: "3.10" cache: pip @@ -267,17 +273,49 @@ 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 + concurrency: + group: lumibot-paper-broker-gate-${{ github.repository }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Python 3.10 + uses: actions/setup-python@v6 + 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 tests.test_live_broker_gate + 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 +326,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/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 new file mode 100644 index 000000000..080e56dd6 --- /dev/null +++ b/tests/test_live_broker_gate.py @@ -0,0 +1,313 @@ +"""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 + +import pytest + +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 +from lumibot.traders.trader import Trader + +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}") + + +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) + + +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 + self.bar_count = 0 + self.call_expirations = 0 + self.put_expirations = 0 + 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 + 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: + """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. + assert isinstance(broker.is_market_open(), bool) + broker.is_market_open = lambda: True + + strategy = strategy_class( + 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) + + return strategy + + +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, + ) + 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: + 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: + 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: + 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() + + +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() + + +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()