-
Notifications
You must be signed in to change notification settings - Fork 18
Description
Summary
The LEAN Alpaca brokerage does not properly handle TradeEvent.Expired events from Alpaca, causing DAY orders that expire at market close to remain in
GetOpenOrders()
indefinitely with status OrderStatus.Submitted.
Impact
Severity: High
Issue: Expired DAY orders are never removed from tracking
Consequence: Algorithm state becomes inconsistent
Reproduction
Place a DAY order during extended hours (e.g., limit sell order)
Order doesn't fill before market close (8PM ET / 01:00 UTC)
Alpaca auto-expires the order and sends TradeEvent.Expired event
LEAN receives the event but doesn't process it correctly, GetOpenOrders() still returns the order with status="Submitted"
Root Cause Analysis
Bug Location 1: Missing "Expired" Event Handling
File: Lean.Brokerages.Alpaca/QuantConnect.AlpacaBrokerage/AlpacaBrokerage.cs
Method: HandleTradeUpdate (lines 430-528)
Line: 449-487
The switch statement that handles trade events does not include a case for TradeEvent.Expired:
switch (obj.Event)
{
case TradeEvent.New:
case TradeEvent.PendingNew:
// handled
return;
case TradeEvent.Rejected:
case TradeEvent.Canceled:
case TradeEvent.Replaced:
// handled - order removed from tracking
return;
case TradeEvent.Fill:
// handled
break;
case TradeEvent.PartialFill:
// handled
break;
// ... other cases ...
default:
Log.Trace($"{nameof(AlpacaBrokerage)}.{nameof(HandleTradeUpdate)}.Event: {obj.Event}. TradeUpdate: {obj}");
return; // ❌ EXPIRED EVENTS HIT THIS DEFAULT CASE
}
When TradeEvent.Expired arrives, it hits the default case, gets logged, and returns early without removing the order from tracking.
Bug Location 2: Missing "Expired" Status Mapping
File: Lean.Brokerages.Alpaca/QuantConnect.AlpacaBrokerage/AlpacaBrokerage.cs
Method: GetOrderStatus (lines 799-820)
The GetOrderStatus method doesn't map TradeEvent.Expired to any OrderStatus:
private static Orders.OrderStatus GetOrderStatus(TradeEvent tradeEvent)
{
switch (tradeEvent)
{
case TradeEvent.PendingNew:
return Orders.OrderStatus.New;
case TradeEvent.New:
return Orders.OrderStatus.Submitted;
case TradeEvent.Rejected:
return Orders.OrderStatus.Invalid;
case TradeEvent.Canceled:
return Orders.OrderStatus.Canceled;
case TradeEvent.Replaced:
return Orders.OrderStatus.UpdateSubmitted;
case TradeEvent.Fill:
return Orders.OrderStatus.Filled;
case TradeEvent.PartialFill:
return Orders.OrderStatus.PartiallyFilled;
default:
return Orders.OrderStatus.New; // ❌ EXPIRED→NEW IS WRONG
}
}
If TradeEvent.Expired is passed here, it returns OrderStatus.New which is incorrect.
Proposed Fix
Fix 1: Handle Expired Events in HandleTradeUpdate
Add TradeEvent.Expired to the canceled orders case:
case TradeEvent.Rejected:
case TradeEvent.Canceled:
case TradeEvent.Expired: // ✅ ADD THIS
if (_duplicationExecutionOrderIdByBrokerageOrderId.Remove(obj.Order.OrderId))
{
// ... existing logic to fire OrderEvent ...
}
return;
Fix 2: Map Expired to Canceled Status
Update GetOrderStatus to treat expired orders as canceled:
case TradeEvent.Canceled:
case TradeEvent.Expired: // ✅ ADD THIS
return Orders.OrderStatus.Canceled;