Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions .github/workflows/cicd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ jobs:

# CI target: keep PR checks fast and deterministic.
# Downloader + apitests are run separately / opt-in.
PYTEST_MARKERS='not apitest and not downloader'
PYTEST_MARKERS='not apitest and not downloader and not broker_strategy_live'
export PYTEST_MARKERS

echo "Shard ${SHARD_INDEX}/${SHARD_TOTAL} markers=${PYTEST_MARKERS}"
Expand Down Expand Up @@ -223,7 +223,7 @@ jobs:
run: |
set -euo pipefail

PYTEST_MARKERS='not apitest and not downloader'
PYTEST_MARKERS='not apitest and not downloader and not broker_strategy_live'
export PYTEST_MARKERS

echo "Shard ${SHARD_INDEX}/${SHARD_TOTAL} markers=${PYTEST_MARKERS}"
Expand Down Expand Up @@ -267,17 +267,67 @@ 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)

broker-live-strategy-tests:
name: Broker Live Strategy Tests
runs-on: ubuntu-latest
timeout-minutes: 20
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: |
echo "Set AIOHTTP_NO_EXTENSIONS=$AIOHTTP_NO_EXTENSIONS so that aiohttp doesn't try to install C extensions"
python -m pip install --upgrade pip
pip install requests
pip install -r requirements_dev.txt

- name: Validate broker paper secrets
run: |
set -euo pipefail
python - <<'PY'
import os

required = [
"ALPACA_TEST_API_KEY",
"ALPACA_TEST_API_SECRET",
"TRADIER_TEST_ACCESS_TOKEN",
"TRADIER_TEST_ACCOUNT_NUMBER",
]
missing = [name for name in required if not os.environ.get(name)]
if missing:
raise SystemExit("Missing broker live strategy test secrets: " + ", ".join(missing))

print("Broker live strategy test secrets present.")
PY

- name: Run broker live strategy tests
run: |
set -euo pipefail
timeout 900 python -m pytest -q --tb=short -x \
-m "broker_strategy_live" \
tests/test_broker_live_strategy_run_apitest.py

LintAndTest:
name: LintAndTest
runs-on: ubuntu-latest
if: always()
needs: [lint, unit-tests, backtest-tests]
needs: [lint, unit-tests, backtest-tests, broker-live-strategy-tests]
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 "broker-live-strategy-tests: ${{ needs.broker-live-strategy-tests.result }}"

if [ "${{ needs.lint.result }}" != "success" ]; then
exit 1
Expand All @@ -288,3 +338,6 @@ jobs:
if [ "${{ needs.backtest-tests.result }}" != "success" ]; then
exit 1
fi
if [ "${{ needs.broker-live-strategy-tests.result }}" != "success" ]; then
exit 1
fi
26 changes: 24 additions & 2 deletions docs/SMART_LIMIT_LIVE_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,29 @@ Notes:
- Tradier smoke includes a paper connectivity check; the submit/cancel lifecycle test requires explicit live config
(`TRADIER_IS_PAPER=false`) and should be run only when you intend to touch the live API.

### 4) Run live matrix (market hours)
### 4) Strict CI strategy-run smoke

CI runs a strict paper-broker strategy path for Alpaca and Tradier:

- instantiates the real broker from `ALPACA_TEST_*` / `TRADIER_TEST_*` secrets;
- submits, polls, and cancels a direct broker GTC limit order as a broker contract preflight;
- runs `Trader.run_all(run_once=True)`;
- executes `before_starting_trading`, `on_trading_iteration`, and `on_strategy_end`;
- submits a deep non-marketable AAPL GTC limit order through `Strategy.submit_order(...)`;
- polls the real paper broker for the order id/status;
- cancels through `Strategy.cancel_order(...)`;
- fails if required secrets are missing or the broker order does not reach canceled state.

The strategy-run test overrides only the executor's market-open gate so pull-request CI is not tied to US equity
session timing. Broker config and submitted assets stay unchanged; the paper broker still accepts or rejects the real
GTC order.

```bash
/Users/robertgrzesik/bin/safe-timeout 1200s python3 -m pytest -q -m "broker_strategy_live" \
tests/test_broker_live_strategy_run_apitest.py
```

### 5) Run live matrix (market hours)

These are heavier tests that add:

Expand All @@ -60,7 +82,7 @@ These are heavier tests that add:
tests/test_smart_limit_live_matrix_tradier.py
```

### 5) Benchmarks (market hours; not a pytest)
### 6) Benchmarks (market hours; not a pytest)

Benchmarks are in `scripts/` and write CSVs to `logs/`. These are used for price-improvement statistics and are not
treated as strict pass/fail (paper fills can be unrealistic).
68 changes: 64 additions & 4 deletions lumibot/brokers/alpaca.py
Original file line number Diff line number Diff line change
Expand Up @@ -964,7 +964,16 @@ def _flatten_order(self, order):



def _submit_orders(self, orders, is_multileg=False, order_type=None, duration="day", price=None):
def _submit_orders(
self,
orders,
is_multileg=False,
order_type=None,
duration="day",
price=None,
take_profit=None,
stop_loss=None,
):
"""
Submit multiple orders to the broker. Supports multi-leg (MLeg) orders for options.
"""
Expand All @@ -973,23 +982,40 @@ def _submit_orders(self, orders, is_multileg=False, order_type=None, duration="d

if is_multileg:
tag = orders[0].tag if hasattr(orders[0], "tag") and orders[0].tag else orders[0].strategy
parent_order = self._submit_multileg_order(orders, order_type, duration, price, tag)
parent_order = self._submit_multileg_order(
orders,
order_type,
duration,
price,
tag,
take_profit=take_profit,
stop_loss=stop_loss,
)
return [parent_order]
else:
sub_orders = []
for order in orders:
sub_orders.append(self._submit_order(order))
return sub_orders

def _submit_multileg_order(self, orders, order_type="limit", duration="day", price=None, tag=None):
def _submit_multileg_order(
self,
orders,
order_type="limit",
duration="day",
price=None,
tag=None,
take_profit=None,
stop_loss=None,
):
"""
Submit a multi-leg (MLeg) options order to Alpaca.

Note:
- Tradier uses "credit" for net credit (receive premium) and "debit" for net debit (pay premium).
- Alpaca only supports "market" and "limit" for multi-leg orders.
- We convert "credit", "debit", and "even" to "limit" for Alpaca, as both are limit orders in Alpaca's API.
- The sign of the limit price (positive/negative) is not used by Alpaca to distinguish credit/debit.
- Bracket exits use Alpaca's take_profit and stop_loss fields on the same mleg request.
- Alpaca requires that the leg ratio quantities are relatively prime (GCD == 1).
"""
requested_multileg_type = order_type if order_type in ("credit", "debit", "even") else None
Expand Down Expand Up @@ -1087,7 +1113,15 @@ def _submit_multileg_order(self, orders, order_type="limit", duration="day", pri
if price is not None:
# Ensure limit price is at most 2 decimal places (Alpaca requirement)
limit_price = round(float(price), 2)
if requested_multileg_type == "credit":
limit_price = -abs(limit_price)
elif requested_multileg_type == "debit":
limit_price = abs(limit_price)
kwargs["limit_price"] = limit_price
if take_profit is not None:
kwargs["take_profit"] = self._format_multileg_take_profit(take_profit)
if stop_loss is not None:
kwargs["stop_loss"] = self._format_multileg_stop_loss(stop_loss)
# Submit order
try:
response = self.api.submit_order(order_data=OrderData(**kwargs))
Expand Down Expand Up @@ -1116,6 +1150,32 @@ def _submit_multileg_order(self, orders, order_type="limit", duration="day", pri
o.set_error(e)
raise

@staticmethod
def _format_multileg_take_profit(take_profit):
if not isinstance(take_profit, dict):
raise ValueError("multi-leg take_profit must be a dict")
limit_price = take_profit.get("limit_price")
if limit_price is None:
raise ValueError("multi-leg take_profit requires limit_price")
return {"limit_price": round(float(limit_price), 2)}

@staticmethod
def _format_multileg_stop_loss(stop_loss):
if not isinstance(stop_loss, dict):
raise ValueError("multi-leg stop_loss must be a dict")
unsupported = {"trail_price", "trail_percent"} & set(stop_loss)
if unsupported:
names = ", ".join(sorted(unsupported))
raise ValueError(f"multi-leg stop_loss does not support {names}")
payload = {}
if stop_loss.get("stop_price") is not None:
payload["stop_price"] = round(float(stop_loss["stop_price"]), 2)
if stop_loss.get("limit_price") is not None:
payload["limit_price"] = round(float(stop_loss["limit_price"]), 2)
if "stop_price" not in payload:
raise ValueError("multi-leg stop_loss requires stop_price")
return payload


def _submit_order(self, order):
"""Submit an order for an asset (single-leg, including options)"""
Expand Down
7 changes: 5 additions & 2 deletions lumibot/strategies/_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from lumibot._lazy_imports import LazyClassMeta, LazyModule, LazyStrategyLogger, lazy_class, lazy_typing


def _env_flag_enabled(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "y", "on")

Expand Down Expand Up @@ -81,7 +82,6 @@ def get_default_data_source():
DISCORD_WEBHOOK_URL,
HIDE_POSITIONS,
HIDE_TRADES,
IS_BACKTESTING,
LIVE_CONFIG,
LOG_BACKTEST_PROGRESS_TO_FILE,
LUMIWEALTH_API_KEY,
Expand All @@ -96,6 +96,9 @@ def get_default_data_source():
get_default_broker,
get_default_data_source,
)
from ..credentials import (
IS_BACKTESTING as IS_BACKTESTING,
)

mdates = LazyModule("matplotlib.dates")
pd = LazyModule("pandas")
Expand All @@ -121,7 +124,7 @@ def get_default_data_source():
DATA_SOURCE = None

if TYPE_CHECKING:
from ..entities import CashEvent, Data, Position
from ..entities import CashEvent, Data


def colored(*args, **kwargs):
Expand Down
9 changes: 0 additions & 9 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
polygon-api-client>=1.13.3
alpaca-py>=0.42.0
alpha_vantage
ibapi==9.81.1.post1
yfinance>=0.2.61
matplotlib>=3.3.3
quandl
numpy>=1.20.0,<2.5.0
pandas>=2.2.0
polars>=1.32.3
pandas_market_calendars>=5.1.0
pandas-ta-classic>=0.3.14b0
plotly>=5.18.0
sqlalchemy
bcrypt
pytest
yappi>=1.6.0
scipy>=1.14.0
quantstats-lumi>=1.1.5,<1.2.0
Expand All @@ -29,13 +25,9 @@ lumiwealth-tradier>=0.1.18
py-clob-client-v2>=1.0.1
websockets>=15.0.1
pytz
psycopg2-binary
exchange_calendars>=4.6.0
duckdb
tabulate
databento>=0.42.0
holidays
psutil
openai
setuptools<81
google-adk[extensions]>=2.0.0,<3.0.0
Expand All @@ -44,7 +36,6 @@ litellm>=1.83.7,<=1.83.14
anyio>=4.10.0
mcp>=1.26.0
schwab-py>=1.5.0
Flask>=2.3
free-proxy
requests-oauthlib
boto3>=1.40.64
3 changes: 2 additions & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ known-third-party=lumibot
[tool:pytest]
markers =
apitest: marks tests as API tests (deselect with '-m "not apitest"')
broker_strategy_live: strict paper-broker strategy-run tests that submit, poll, and cancel real orders
smartlimit_matrix: larger SMART_LIMIT live matrix tests (run with '-m smartlimit_matrix')
downloader: marks tests that require the shared Theta downloader service
polymarket: marks tests that require Polymarket CLOB credentials or live market data
Expand All @@ -44,7 +45,7 @@ norecursedirs = docs .* *.egg* appdir jupyter *pycache* venv* .cache* .coverage*

# .coveragerc to control coverag.py
[coverage:run]
command_line = -m pytest -m "not apitest"
command_line = -m pytest -m "not apitest and not broker_strategy_live"
branch = True
omit =
# * so you can get all dirs
Expand Down
10 changes: 0 additions & 10 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,9 @@ def _maybe_copy_theta_terminal(self):
install_requires=[
"polygon-api-client>=1.13.3",
"alpaca-py>=0.42.0",
"alpha_vantage",
"ibapi==9.81.1.post1",
"yfinance>=0.2.61",
"matplotlib>=3.3.3",
"quandl",
# NumPy 2.5 emits noisy timedelta deprecation warnings through pandas 2.x calendar paths.
"numpy>=1.20.0,<2.5.0",
"pandas>=2.2.0",
Expand All @@ -69,8 +67,6 @@ def _maybe_copy_theta_terminal(self):
"pandas-ta-classic>=0.3.14b0",
"plotly>=5.18.0",
"sqlalchemy",
"bcrypt",
"pytest",
"yappi>=1.6.0",
# SciPy 1.14.0+ supports NumPy 2.x
"scipy>=1.14.0",
Expand All @@ -87,14 +83,9 @@ def _maybe_copy_theta_terminal(self):
"lumiwealth-tradier>=0.1.18",
"py-clob-client-v2>=1.0.1",
"pytz",
"psycopg2-binary",
# Exchange calendars 4.6.0+ supports NumPy 2.x
"exchange_calendars>=4.6.0",
"duckdb",
"tabulate",
"databento>=0.42.0",
"holidays",
"psutil",
"openai",
"setuptools<81",
"google-adk[extensions]>=2.0.0,<3.0.0",
Expand All @@ -103,7 +94,6 @@ def _maybe_copy_theta_terminal(self):
"anyio>=4.10.0",
"mcp>=1.26.0",
"schwab-py>=1.5.0",
"Flask>=2.3",
"free-proxy",
"requests-oauthlib",
"boto3>=1.40.64",
Expand Down
Loading
Loading