Skip to content
Merged
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
12 changes: 9 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@
# RH_TOTP_CODE=your_totp_secret
# RH_AUTOMATED_ACCOUNT_NUMBER=your_account_number

# Optional: Alpaca API keys for fill quality auditing
# Free tier gives IEX quotes (~2% market coverage)
# Paid tier ($99/mo) gives SIP NBBO (full market)
# Alpaca broker (single-leg options trading via utils/alpaca_broker.py).
# Defaults to PAPER trading. The live Alpaca account is intentionally not
# funded yet, so leave ALPACA_PAPER=true unless you know you want live money.
# ALPACA_API_KEY_ID=your_key_id
# ALPACA_API_SECRET_KEY=your_secret_key
# ALPACA_PAPER=true # "false" flips to https://api.alpaca.markets (live)

# Legacy Alpaca aliases (data-only fill auditing, free tier = IEX quotes).
# Kept here so older fill-audit scripts that read these names still work.
# ALPACA_API_KEY=your_key
# ALPACA_SECRET_KEY=your_secret

Expand Down
6 changes: 3 additions & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ pandas>=2.0.0
matplotlib>=3.7.0
fpdf2>=2.7.0

# Optional: Execution quality cross-referencing
# alpaca-py>=0.30.0
# Note: uncomment above if you want Alpaca NBBO fill auditing
# Alpaca broker support (paper trading by default).
# Used by utils/alpaca_broker.py and scripts/place_option_order_alpaca.py.
alpaca-py>=0.30.0

# Optional: Interactive Brokers (IBKR) execution via TWS / IB Gateway
# Required only when running live IBKR orders (dry-run paths import lazily)
Expand Down
121 changes: 121 additions & 0 deletions scripts/place_option_order_alpaca.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""
Place a single-leg option order via Alpaca (paper-trading by default).

Defaults:
- Paper endpoint (https://paper-api.alpaca.markets)
- Dry run (no order is sent at all)

Usage:
# Dry run against the paper endpoint (no order is placed)
python scripts/place_option_order_alpaca.py XLY 2026-09-18 120 call 1 4.50 buy

# Actually send the order to the paper endpoint
python scripts/place_option_order_alpaca.py XLY 2026-09-18 120 call 1 4.50 buy --live

# Take-profit close on a paper position
python scripts/place_option_order_alpaca.py XLY 2026-09-18 120 call 1 6.75 sell --live

NOTE on flag semantics:
--live Send to the *paper* endpoint (i.e. actually call submit_order).
The Alpaca account is not yet funded, so live-money trading is
intentionally NOT wired up to a CLI flag.

# TODO(once funded): add a --real-money flag that sets ALPACA_PAPER=false /
# AlpacaBroker(paper=False) so this same CLI can hit the live endpoint.
"""

import os
import sys

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.alpaca_broker import AlpacaBroker # noqa: E402


USAGE = """\
Usage:
python scripts/place_option_order_alpaca.py SYMBOL EXPIRATION STRIKE TYPE QTY PRICE SIDE [--live]

SYMBOL Underlying ticker (e.g. XLY)
EXPIRATION YYYY-MM-DD (e.g. 2026-09-18)
STRIKE Strike price (e.g. 120)
TYPE put | call
QTY Number of contracts (1 = 100 shares)
PRICE Limit price per contract share (e.g. 4.50)
SIDE buy -> buy-to-open
sell -> sell-to-close (must already hold the contract)
--live Actually send the order to Alpaca's PAPER endpoint.
Default is DRY RUN (no order sent).

Examples:
Dry-run open: python scripts/place_option_order_alpaca.py XLY 2026-09-18 120 call 1 4.50 buy
Paper open: python scripts/place_option_order_alpaca.py XLY 2026-09-18 120 call 1 4.50 buy --live
Paper take-profit: python scripts/place_option_order_alpaca.py XLY 2026-09-18 120 call 1 6.75 sell --live
"""


def main():
if len(sys.argv) < 8:
print(USAGE)
sys.exit(1)
try:
symbol = sys.argv[1].upper()
expiration = sys.argv[2]
strike = float(sys.argv[3])
option_type = sys.argv[4].lower()
quantity = int(sys.argv[5])
price = float(sys.argv[6])
side = sys.argv[7].lower()
dry_run = '--live' not in sys.argv
except (ValueError, IndexError) as e:
print(f"[ERR] Could not parse args: {e}\n")
print(USAGE)
sys.exit(1)

if option_type not in ('put', 'call'):
print(f"[ERR] TYPE must be 'put' or 'call', got {option_type!r}")
sys.exit(1)
if side not in ('buy', 'sell'):
print(f"[ERR] SIDE must be 'buy' or 'sell', got {side!r}")
sys.exit(1)
if quantity <= 0:
print("[ERR] QTY must be positive")
sys.exit(1)
if price <= 0:
print("[ERR] PRICE must be positive")
sys.exit(1)

# paper=None lets AlpacaBroker resolve from the ALPACA_PAPER env var,
# which defaults to true. There is intentionally no --real-money flag yet.
broker = AlpacaBroker(paper=None)

if side == 'buy':
order = broker.place_option_buy_limit_order(
symbol=symbol,
expiration=expiration,
strike=strike,
option_type=option_type,
quantity=quantity,
price=price,
dry_run=dry_run,
)
else:
order = broker.place_option_sell_limit_order(
symbol=symbol,
expiration=expiration,
strike=strike,
option_type=option_type,
quantity=quantity,
price=price,
dry_run=dry_run,
)

if order and isinstance(order, dict) and order.get('id'):
print(f"\n[OK] Order ID: {order['id']} state={order.get('status', 'N/A')}\n")
elif dry_run:
print("\n[OK] Dry run completed - no real order placed\n")
else:
print("\n[ERR] Order failed - see error above\n")


if __name__ == "__main__":
main()
184 changes: 184 additions & 0 deletions tests/test_alpaca_broker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""
Smoke tests for utils.alpaca_broker.AlpacaBroker.

These tests fully mock the alpaca-py TradingClient so no network calls
are made. CI has no Alpaca credentials and no internet access for
brokers, so the tests assert against the mock instead.
"""

from __future__ import annotations

import sys
import types
from unittest.mock import MagicMock, patch

import pytest


# --------------------------------------------------------------------------
# alpaca-py is an optional dependency for the rest of the codebase. If
# it's not installed in the test env, stub just enough of it for the
# AlpacaBroker import to succeed.
# --------------------------------------------------------------------------
def _ensure_alpaca_stub():
try:
import alpaca.trading.client # noqa: F401
import alpaca.trading.requests # noqa: F401
import alpaca.trading.enums # noqa: F401
return
except Exception:
pass

alpaca_mod = types.ModuleType('alpaca')
trading_mod = types.ModuleType('alpaca.trading')
client_mod = types.ModuleType('alpaca.trading.client')
requests_mod = types.ModuleType('alpaca.trading.requests')
enums_mod = types.ModuleType('alpaca.trading.enums')

class _StubTradingClient:
def __init__(self, *a, **kw):
pass

class _StubLimitOrderRequest:
def __init__(self, **kw):
self.__dict__.update(kw)

class _StubEnum(str):
pass

class _OrderSide:
BUY = _StubEnum('buy')
SELL = _StubEnum('sell')

class _TimeInForce:
DAY = _StubEnum('day')
GTC = _StubEnum('gtc')
IOC = _StubEnum('ioc')
FOK = _StubEnum('fok')

class _PositionIntent:
BUY_TO_OPEN = _StubEnum('buy_to_open')
SELL_TO_CLOSE = _StubEnum('sell_to_close')
BUY_TO_CLOSE = _StubEnum('buy_to_close')
SELL_TO_OPEN = _StubEnum('sell_to_open')

client_mod.TradingClient = _StubTradingClient
requests_mod.LimitOrderRequest = _StubLimitOrderRequest
enums_mod.OrderSide = _OrderSide
enums_mod.TimeInForce = _TimeInForce
enums_mod.PositionIntent = _PositionIntent

sys.modules['alpaca'] = alpaca_mod
sys.modules['alpaca.trading'] = trading_mod
sys.modules['alpaca.trading.client'] = client_mod
sys.modules['alpaca.trading.requests'] = requests_mod
sys.modules['alpaca.trading.enums'] = enums_mod


_ensure_alpaca_stub()


from utils.alpaca_broker import AlpacaBroker, build_opra_symbol # noqa: E402


# --------------------------------------------------------------------------
# OPRA encoder
# --------------------------------------------------------------------------
def test_build_opra_symbol_call():
# XLY 2026-09-18 $120 CALL -> XLY260918C00120000
assert build_opra_symbol('XLY', '2026-09-18', 120.0, 'call') == 'XLY260918C00120000'


def test_build_opra_symbol_put_fractional_strike():
# SPY 2026-12-19 $432.5 PUT -> SPY261219P00432500
assert build_opra_symbol('SPY', '2026-12-19', 432.5, 'put') == 'SPY261219P00432500'


# --------------------------------------------------------------------------
# Broker behavior - dry_run gating + single submit_order call on live
# --------------------------------------------------------------------------
@pytest.fixture
def broker_with_mock_client():
"""Construct an AlpacaBroker whose TradingClient is replaced with a MagicMock."""
with patch('alpaca.trading.client.TradingClient') as mock_cls:
mock_client = MagicMock(name='TradingClient')

# Account stub for the buying-power pre-trade check
acct = MagicMock()
acct.options_buying_power = '1000000'
acct.buying_power = '1000000'
acct.cash = '500000'
acct.account_number = 'PA-TEST-1'
acct.status = 'ACTIVE'
mock_client.get_account.return_value = acct

# submit_order stub returns an object with .id / .status
submitted = MagicMock()
submitted.id = 'order-abc-123'
submitted.status = 'accepted'
submitted.symbol = 'XLY260918C00120000'
# No model_dump -> _order_to_dict will fall back to attr scrape
del submitted.model_dump
mock_client.submit_order.return_value = submitted

# No positions in the way (sell-to-close path is not exercised here)
mock_client.get_all_positions.return_value = []

mock_cls.return_value = mock_client

# Provide credentials so AlpacaBroker doesn't whine
with patch.dict('os.environ', {
'ALPACA_API_KEY_ID': 'test-key',
'ALPACA_API_SECRET_KEY': 'test-secret',
'ALPACA_PAPER': 'true',
}, clear=False):
broker = AlpacaBroker(paper=True)
broker._client = mock_client # belt and suspenders
yield broker, mock_client


def test_dry_run_does_not_call_submit_order(broker_with_mock_client):
broker, mock_client = broker_with_mock_client

result = broker.place_option_buy_limit_order(
symbol='XLY',
expiration='2026-09-18',
strike=120.0,
option_type='call',
quantity=1,
price=4.50,
dry_run=True,
)

# Dry run returns an empty dict (no order actually placed)
assert result == {}
# Crucially: the live submit_order endpoint is NOT called
mock_client.submit_order.assert_not_called()


def test_live_calls_submit_order_once_with_correct_opra(broker_with_mock_client):
broker, mock_client = broker_with_mock_client

result = broker.place_option_buy_limit_order(
symbol='XLY',
expiration='2026-09-18',
strike=120.0,
option_type='call',
quantity=2,
price=4.50,
dry_run=False,
)

# submit_order called exactly once
assert mock_client.submit_order.call_count == 1

# Pull the order request payload and verify the OPRA symbol + key fields
_, kwargs = mock_client.submit_order.call_args
order_data = kwargs.get('order_data')
assert order_data is not None, "submit_order must be called with order_data=..."
assert getattr(order_data, 'symbol', None) == 'XLY260918C00120000'
assert getattr(order_data, 'qty', None) == 2
assert float(getattr(order_data, 'limit_price', 0)) == 4.50

# And the broker surfaces the order id back to the caller
assert result.get('id') == 'order-abc-123'
Loading
Loading