diff --git a/src/ml4t/live/brokers/alpaca.py b/src/ml4t/live/brokers/alpaca.py index b06c1cc..a85e169 100644 --- a/src/ml4t/live/brokers/alpaca.py +++ b/src/ml4t/live/brokers/alpaca.py @@ -21,6 +21,7 @@ from alpaca.trading.client import TradingClient from alpaca.trading.enums import OrderSide as AlpacaOrderSide from alpaca.trading.enums import OrderStatus as AlpacaOrderStatus +from alpaca.trading.enums import PositionSide as AlpacaPositionSide from alpaca.trading.enums import QueryOrderStatus, TimeInForce from alpaca.trading.requests import ( GetOrdersRequest, @@ -906,6 +907,12 @@ async def _sync_positions(self) -> None: raise RuntimeError("Alpaca positions snapshot contains an empty symbol") if not math.isfinite(quantity) or quantity == 0: raise RuntimeError("Alpaca positions snapshot contains an invalid quantity") + if pos.side == AlpacaPositionSide.SHORT: + quantity = -abs(quantity) + elif pos.side != AlpacaPositionSide.LONG: + raise RuntimeError("Alpaca positions snapshot contains an invalid side") + else: + quantity = abs(quantity) if not math.isfinite(entry_price) or entry_price < 0: raise RuntimeError("Alpaca positions snapshot contains an invalid entry price") if not math.isfinite(current_price) or current_price < 0: diff --git a/tests/unit/test_alpaca_broker.py b/tests/unit/test_alpaca_broker.py index 95822a1..d9445a6 100644 --- a/tests/unit/test_alpaca_broker.py +++ b/tests/unit/test_alpaca_broker.py @@ -10,6 +10,7 @@ from alpaca.common.enums import BaseURL from alpaca.trading.enums import OrderStatus as AlpacaOrderStatus from alpaca.trading.enums import TimeInForce +from alpaca.trading.models import Position as AlpacaPosition from alpaca.trading.requests import MarketOrderRequest from ml4t.backtest.types import Order, OrderSide, OrderStatus, OrderType, Position @@ -215,6 +216,59 @@ async def test_sync_positions_accepts_zero_cost_basis_and_current_price(self): assert broker.positions["AAPL"].entry_price == 0.0 assert broker.positions["AAPL"].current_price == 0.0 + @pytest.mark.asyncio + async def test_sync_positions_signs_short_holdings(self): + """Alpaca reports an unsigned qty plus a long/short side.""" + client = MagicMock() + client.get_all_positions.return_value = [ + MockAlpacaPosition("AAPL", "50", "160.00", side="short", current_price="155.00"), + ] + broker = AlpacaBroker(api_key="PKTEST", secret_key="SECRET") + broker._trading_client = client + + await broker._sync_positions() + + assert broker.positions["AAPL"].quantity == -50.0 + assert broker.get_position("AAPL").quantity == -50.0 + + @patch("ml4t.live.brokers.alpaca.TradingClient") + @patch("ml4t.live.brokers.alpaca.TradingStream") + async def test_connected_short_is_closed_with_a_buy_to_cover( + self, mock_stream_class, mock_client_class + ): + client = mock_client_class.return_value + client.get_account.return_value = MockAlpacaAccount() + client.get_orders.return_value = [] + client.get_all_positions.return_value = [ + AlpacaPosition.model_validate( + { + "asset_id": "00000000-0000-0000-0000-000000000001", + "symbol": "AAPL", + "exchange": "NASDAQ", + "asset_class": "us_equity", + "avg_entry_price": "160", + "qty": "50", + "side": "short", + "cost_basis": "8000", + "current_price": "155", + } + ) + ] + client.submit_order.return_value = MockAlpacaOrder(qty="50") + broker = AlpacaBroker(api_key="PKTEST", secret_key="TEST") + await broker.connect() + try: + assert (await broker.get_position_async("AAPL")).quantity == -50 + order = await broker.close_position_async("AAPL") + + assert order.side is OrderSide.BUY + assert order.quantity == 50 + request = client.submit_order.call_args.args[0] + assert request.side.value == "buy" + assert request.qty == 50 + finally: + await broker.disconnect() + @pytest.mark.asyncio @patch("ml4t.live.brokers.alpaca.TradingClient") @patch("ml4t.live.brokers.alpaca.TradingStream")