diff --git a/.env.example b/.env.example index ff6fd74..e817b21 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/requirements.txt b/requirements.txt index 6beabd4..853f7d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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) diff --git a/scripts/place_option_order_alpaca.py b/scripts/place_option_order_alpaca.py new file mode 100644 index 0000000..e704971 --- /dev/null +++ b/scripts/place_option_order_alpaca.py @@ -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() diff --git a/tests/test_alpaca_broker.py b/tests/test_alpaca_broker.py new file mode 100644 index 0000000..e21fd89 --- /dev/null +++ b/tests/test_alpaca_broker.py @@ -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' diff --git a/utils/alpaca_broker.py b/utils/alpaca_broker.py new file mode 100644 index 0000000..2de9a35 --- /dev/null +++ b/utils/alpaca_broker.py @@ -0,0 +1,397 @@ +""" +Alpaca Broker Adapter +--------------------- +Single-leg equity-options trading via Alpaca's API. Defaults to **paper +trading** (https://paper-api.alpaca.markets) so this is safe to run before +the live account is funded. + +Env vars (loaded with python-dotenv, matching utils/rh_auth.py:21): + ALPACA_API_KEY_ID - Alpaca API key id + ALPACA_API_SECRET_KEY - Alpaca API secret + ALPACA_PAPER - "true"/"false", default "true" + +Surface intentionally mirrors the new option methods on +``utils.safe_cash_bot.SafeCashBot``: same banner format, same dry_run +gating, same [OK]/[ERR]/[WARN] prefixes. + +Single-leg only - spreads / multi-leg are out of scope for this adapter. +""" + +from __future__ import annotations + +import os +from datetime import datetime +from typing import Optional + +from dotenv import load_dotenv + + +# -------------------------------------------------------------------------- +# OPRA / OCC option symbol encoding +# -------------------------------------------------------------------------- +def build_opra_symbol(symbol: str, expiration: str, strike: float, option_type: str) -> str: + """ + Build an OCC/OPRA-formatted option symbol that Alpaca accepts. + + Format: ROOT (up to 6 chars, padded right) + YYMMDD + C/P + strike*1000 + zero-padded to 8 digits. + + Example: + build_opra_symbol('XLY', '2026-09-18', 120.0, 'call') + -> 'XLY260918C00120000' + """ + option_type = option_type.lower() + if option_type not in ('put', 'call'): + raise ValueError(f"option_type must be 'put' or 'call', got {option_type!r}") + try: + exp_dt = datetime.strptime(expiration, '%Y-%m-%d') + except ValueError as e: + raise ValueError(f"expiration must be YYYY-MM-DD, got {expiration!r}: {e}") + + root = symbol.upper() + yymmdd = exp_dt.strftime('%y%m%d') + cp = 'C' if option_type == 'call' else 'P' + strike_int = int(round(float(strike) * 1000)) + strike_str = f"{strike_int:08d}" + return f"{root}{yymmdd}{cp}{strike_str}" + + +# -------------------------------------------------------------------------- +# Broker +# -------------------------------------------------------------------------- +class AlpacaBroker: + """ + Thin Alpaca adapter for single-leg equity options. + + Defaults to paper trading. Pass ``paper=False`` (or set + ``ALPACA_PAPER=false`` in the env) to point at the live endpoint - + but the user explicitly does NOT want that wired up to a CLI flag + yet, since the live account is not funded. + """ + + PAPER_URL = "https://paper-api.alpaca.markets" + LIVE_URL = "https://api.alpaca.markets" + + def __init__(self, paper: Optional[bool] = None): + load_dotenv() + + # Resolve paper flag: explicit arg > env var > default True + if paper is None: + env_paper = os.getenv('ALPACA_PAPER', 'true').strip().lower() + paper = env_paper not in ('false', '0', 'no') + self.paper = bool(paper) + + self.api_key = os.getenv('ALPACA_API_KEY_ID') + self.api_secret = os.getenv('ALPACA_API_SECRET_KEY') + if not self.api_key or not self.api_secret: + print("[ERR] ALPACA_API_KEY_ID / ALPACA_API_SECRET_KEY not set in .env") + + # Lazy import keeps unit tests light and avoids hard dep at import time + from alpaca.trading.client import TradingClient + + self._client = TradingClient( + api_key=self.api_key, + secret_key=self.api_secret, + paper=self.paper, + ) + + mode = "PAPER" if self.paper else "LIVE" + endpoint = self.PAPER_URL if self.paper else self.LIVE_URL + print(f"\n{'='*70}") + print(f"ALPACA BROKER INITIALIZED - {mode}") + print(f"{'='*70}") + print(f" Endpoint: {endpoint}") + print(f" API Key: {(self.api_key or '')[:6]}***") + print(f"{'='*70}\n") + + # ------------------------------------------------------------------ + # Account / quotes + # ------------------------------------------------------------------ + def get_account(self) -> dict: + """Return a small dict view of the Alpaca account.""" + try: + acct = self._client.get_account() + data = { + 'account_number': getattr(acct, 'account_number', None), + 'buying_power': float(getattr(acct, 'buying_power', 0) or 0), + 'cash': float(getattr(acct, 'cash', 0) or 0), + 'status': str(getattr(acct, 'status', '')), + } + print(f"\n{'='*70}") + print(f"ALPACA ACCOUNT") + print(f"{'='*70}") + for k, v in data.items(): + print(f" {k}: {v}") + print(f"{'='*70}\n") + return data + except Exception as e: + print(f"[ERR] Error fetching Alpaca account: {e}") + return {} + + def get_option_quote(self, symbol, expiration, strike, option_type) -> dict: + """ + Return latest quote-ish dict for a single option contract. + + Greeks/IV/OI come from the option contract metadata when available; + bid/ask/mid come from the latest market quote. Anything Alpaca + can't surface (e.g. on a free data plan) returns None. + """ + try: + from alpaca.data.historical.option import OptionHistoricalDataClient + from alpaca.data.requests import OptionLatestQuoteRequest + except Exception as e: + print(f"[ERR] alpaca-py data client unavailable: {e}") + return {} + + opra = build_opra_symbol(symbol, expiration, strike, option_type) + + out = { + 'symbol': opra, 'bid': None, 'ask': None, 'mid': None, + 'iv': None, 'delta': None, 'oi': None, 'spot': None, + } + + # Bid/ask via market data + try: + data_client = OptionHistoricalDataClient(self.api_key, self.api_secret) + req = OptionLatestQuoteRequest(symbol_or_symbols=opra) + quotes = data_client.get_option_latest_quote(req) + q = quotes.get(opra) if isinstance(quotes, dict) else None + if q is not None: + bid = float(getattr(q, 'bid_price', 0) or 0) + ask = float(getattr(q, 'ask_price', 0) or 0) + out['bid'] = bid + out['ask'] = ask + if bid and ask: + out['mid'] = round((bid + ask) / 2, 4) + except Exception as e: + print(f" [WARN] Quote fetch failed (continuing): {e}") + + # Greeks / OI via contract metadata + try: + contract = self._client.get_option_contract(opra) + out['iv'] = _safe_float(getattr(contract, 'implied_volatility', None)) + out['delta'] = _safe_float(getattr(contract, 'delta', None)) + out['oi'] = _safe_int(getattr(contract, 'open_interest', None)) + out['spot'] = _safe_float(getattr(contract, 'underlying_price', None)) + except Exception as e: + print(f" [WARN] Contract metadata fetch failed (continuing): {e}") + + return out + + # ------------------------------------------------------------------ + # Order placement + # ------------------------------------------------------------------ + def place_option_buy_limit_order( + self, symbol, expiration, strike, option_type, + quantity, price, dry_run=True, time_in_force='day', + ) -> dict: + """ + Single-leg LIMIT BUY-TO-OPEN on an equity option, via Alpaca. + + Mirrors the banner / dry-run gating style of + ``SafeCashBot.place_option_buy_limit_order``. + """ + return self._place_option_limit_order( + side='buy', position_intent='buy_to_open', + symbol=symbol, expiration=expiration, strike=strike, + option_type=option_type, quantity=quantity, price=price, + dry_run=dry_run, time_in_force=time_in_force, + ) + + def place_option_sell_limit_order( + self, symbol, expiration, strike, option_type, + quantity, price, dry_run=True, time_in_force='day', + ) -> dict: + """ + Single-leg LIMIT SELL-TO-CLOSE on an equity option, via Alpaca. + + Use this to set a take-profit on an existing long option position. + """ + return self._place_option_limit_order( + side='sell', position_intent='sell_to_close', + symbol=symbol, expiration=expiration, strike=strike, + option_type=option_type, quantity=quantity, price=price, + dry_run=dry_run, time_in_force=time_in_force, + ) + + def get_open_option_positions(self) -> list: + """Return open option positions as a list of dicts.""" + try: + positions = self._client.get_all_positions() + except Exception as e: + print(f"[ERR] Error getting positions: {e}") + return [] + + out = [] + for p in positions or []: + asset_class = str(getattr(p, 'asset_class', '') or '').lower() + # alpaca-py returns 'us_option' for option positions + if 'option' not in asset_class: + continue + out.append({ + 'symbol': getattr(p, 'symbol', None), + 'qty': _safe_float(getattr(p, 'qty', None)), + 'avg_entry_price': _safe_float(getattr(p, 'avg_entry_price', None)), + 'market_value': _safe_float(getattr(p, 'market_value', None)), + 'unrealized_pl': _safe_float(getattr(p, 'unrealized_pl', None)), + 'side': str(getattr(p, 'side', '')), + 'asset_class': asset_class, + }) + return out + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + def _place_option_limit_order( + self, side, position_intent, + symbol, expiration, strike, option_type, + quantity, price, dry_run, time_in_force, + ) -> dict: + option_type = option_type.lower() + if option_type not in ('put', 'call'): + print(f"[ERR] option_type must be 'put' or 'call', got {option_type!r}") + return {} + + notional = quantity * price * 100 + opra = build_opra_symbol(symbol, expiration, strike, option_type) + + side_upper = side.upper() + action = "BUY (OPEN)" if side == 'buy' else "SELL (CLOSE)" + mode = "DRY RUN" if dry_run else ("PAPER" if self.paper else "LIVE") + + print(f"\n{'='*70}") + print(f"ALPACA OPTION {action} ORDER - {mode}") + print(f"{'='*70}") + print(f" Endpoint: {self.PAPER_URL if self.paper else self.LIVE_URL}") + print(f" Contract: {symbol} {expiration} ${float(strike):.2f} {option_type.upper()}") + print(f" OPRA: {opra}") + print(f" Side: {side_upper} ({position_intent})") + print(f" Quantity: {quantity} contract(s) ({quantity * 100} shares)") + print(f" Limit: ${float(price):.2f} per share") + notional_label = "Total Debit" if side == 'buy' else "Total Credit" + print(f" {notional_label}: ${notional:.2f}") + print(f" TIF: {str(time_in_force).upper()}") + + # --- Pre-trade validation --- + if side == 'buy': + try: + acct = self._client.get_account() + bp = float(getattr(acct, 'options_buying_power', None) + or getattr(acct, 'buying_power', 0) or 0) + print(f" Buying Power: ${bp:.2f}") + if bp < notional: + print(f"\n[ERR] Insufficient buying power: ${bp:.2f} < ${notional:.2f}") + print(f"{'='*70}\n") + return {} + print(f" Validation: [OK] Buying power sufficient") + except Exception as e: + print(f" [WARN] Buying-power check failed (proceeding): {e}") + else: + # Sell-to-close: confirm we hold enough contracts + try: + positions = self.get_open_option_positions() + held = 0 + for pos in positions: + if pos.get('symbol') == opra: + held = int(abs(pos.get('qty') or 0)) + break + print(f" Held: {held} contract(s)") + if held < quantity: + print(f"\n[ERR] Insufficient open contracts to close: held {held} < sell {quantity}") + print(f"{'='*70}\n") + return {} + print(f" Validation: [OK] Position sufficient") + except Exception as e: + print(f" [WARN] Position check failed (proceeding): {e}") + + if dry_run: + print("\n[WARN] DRY RUN MODE - Order not executed") + print(" To execute, call with dry_run=False") + print(f"{'='*70}\n") + return {} + + # --- Submit --- + try: + from alpaca.trading.requests import LimitOrderRequest + from alpaca.trading.enums import OrderSide, TimeInForce, PositionIntent + + tif_map = { + 'day': TimeInForce.DAY, 'gtc': TimeInForce.GTC, + 'ioc': TimeInForce.IOC, 'fok': TimeInForce.FOK, + } + tif = tif_map.get(str(time_in_force).lower(), TimeInForce.DAY) + + intent_map = { + 'buy_to_open': PositionIntent.BUY_TO_OPEN, + 'sell_to_close': PositionIntent.SELL_TO_CLOSE, + } + + req = LimitOrderRequest( + symbol=opra, + qty=quantity, + side=OrderSide.BUY if side == 'buy' else OrderSide.SELL, + time_in_force=tif, + limit_price=float(price), + position_intent=intent_map[position_intent], + ) + print(f"\nExecuting Alpaca option {side_upper} order...") + order = self._client.submit_order(order_data=req) + + order_id = getattr(order, 'id', None) + order_state = getattr(order, 'status', None) + if order_id: + print(f"[OK] Alpaca option {side} order placed!") + print(f" Order ID: {order_id}") + print(f" State: {order_state or 'N/A'}") + print(f"{'='*70}\n") + return _order_to_dict(order) + + print(f"[ERR] Alpaca option {side} order returned no id") + print(f" Response: {order}") + print(f"{'='*70}\n") + return _order_to_dict(order) + except Exception as e: + print(f"[ERR] Alpaca option {side} order failed: {e}") + print(f"{'='*70}\n") + return {} + + +# -------------------------------------------------------------------------- +# Helpers +# -------------------------------------------------------------------------- +def _safe_float(x): + if x is None: + return None + try: + return float(x) + except (TypeError, ValueError): + return None + + +def _safe_int(x): + if x is None: + return None + try: + return int(x) + except (TypeError, ValueError): + return None + + +def _order_to_dict(order) -> dict: + if order is None: + return {} + if isinstance(order, dict): + return order + # alpaca-py models are pydantic; .model_dump() returns a plain dict + if hasattr(order, 'model_dump'): + try: + return order.model_dump(mode='json') + except Exception: + pass + out = {} + for attr in ('id', 'status', 'symbol', 'qty', 'filled_qty', 'limit_price', + 'side', 'time_in_force', 'position_intent', 'submitted_at'): + val = getattr(order, attr, None) + if val is not None: + out[attr] = val if isinstance(val, (str, int, float, bool)) else str(val) + return out