Skip to content

Commit c962eb9

Browse files
authored
Merge pull request #970 from Lumiwealth/projectx_order_management
2 parents 99fcfa6 + a1908ca commit c962eb9

File tree

13 files changed

+725
-295
lines changed

13 files changed

+725
-295
lines changed

lumibot/backtesting/backtesting_broker.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2910,10 +2910,9 @@ def _ny_date(value):
29102910
if order.order_type == Order.OrderType.LIMIT
29112911
else f"type={order.order_type}, high={high}, low={low}, stop={getattr(order, 'stop_price', None)}"
29122912
)
2913-
strategy.log_message(
2913+
self.logger.debug(
29142914
f"[DIAG] Order remained open for {display_symbol} ({detail}) "
2915-
f"id={order_identifier} at {self.datetime}",
2916-
color="yellow",
2915+
f"id={order_identifier} at {self.datetime}"
29172916
)
29182917
continue
29192918

lumibot/brokers/broker.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,10 +1208,35 @@ def _set_initial_positions(self, strategy):
12081208
if pos.quantity != 0.0:
12091209
self._filled_positions.append(pos)
12101210

1211+
def _clean_order_trackers(self, broker_order):
1212+
"""
1213+
ProjectX has a race condition where an order is filled and added to new trackers at the same time and the
1214+
'new' queue never gets cleared. This function is used to clean the order trackers of any duplicated orders.
1215+
Keep orders that are completed (i.e. filled, canceled, error) and remove any duplicates from the 'new' and
1216+
'unprocessed' trackers.
1217+
"""
1218+
if not broker_order.is_active():
1219+
self._new_orders.remove(broker_order.identifier, key="identifier")
1220+
self._unprocessed_orders.remove(broker_order.identifier, key="identifier")
1221+
self._partially_filled_orders.remove(broker_order.identifier, key="identifier")
1222+
elif broker_order in self._partially_filled_orders:
1223+
self._new_orders.remove(broker_order.identifier, key="identifier")
1224+
self._unprocessed_orders.remove(broker_order.identifier, key="identifier")
1225+
elif broker_order in self._new_orders:
1226+
self._unprocessed_orders.remove(broker_order.identifier, key="identifier")
1227+
12111228
def _process_new_order(self, order):
1212-
# Check if this order already exists in self._new_orders based on the identifier
1213-
if order in self._new_orders:
1214-
return order
1229+
# Don't duplicate orders in the new orders tracker. Check if an order with the same identifier already exists
1230+
# in the tracked orders.
1231+
existing_order = self.get_tracked_order(order.identifier)
1232+
if existing_order:
1233+
# Check if this order already exists in self._new_orders based on the identifier - Do nothing
1234+
if existing_order in self._new_orders:
1235+
return existing_order
1236+
if existing_order not in self._unprocessed_orders:
1237+
return existing_order # Exists in another tracker, return it without adding to prevent duplicates
1238+
else:
1239+
order = existing_order # Use the existing order object from unprocessed and update status
12151240

12161241
self._unprocessed_orders.remove(order.identifier, key="identifier")
12171242
order.status = self.NEW_ORDER

lumibot/brokers/projectx.py

Lines changed: 295 additions & 235 deletions
Large diffs are not rendered by default.

lumibot/brokers/schwab.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,8 +1000,9 @@ def _parse_simple_order(self, schwab_order: dict, strategy_name: str) -> List[Or
10001000

10011001
# Set the status and timestamps
10021002
order.status = status
1003-
order.created_at = entered_time
1004-
order.updated_at = close_time if close_time else entered_time
1003+
order.broker_create_date = entered_time
1004+
order.created_at = order.broker_create_date
1005+
order.broker_update_date = close_time if close_time else entered_time
10051006

10061007
order_objects.append(order)
10071008

lumibot/entities/order.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1235,7 +1235,7 @@ def is_canceled(self):
12351235
bool
12361236
True if the order has been cancelled, False otherwise.
12371237
"""
1238-
return self.status.lower() in ["cancelled", "canceled", "cancel", "error", "expired"]
1238+
return self.status.lower() in ["cancelled", "canceled", "cancel", "cancelling", "error", "expired"]
12391239

12401240
def is_filled(self):
12411241
"""

lumibot/strategies/strategy.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,9 +1396,14 @@ def get_tracked_orders(self):
13961396
self.log_message("Warning: get_tracked_orders() is deprecated, please use get_orders() instead.")
13971397
return self.get_orders()
13981398

1399-
def get_orders(self):
1399+
def get_orders(self, identifiers: list[str] = None):
14001400
"""Get all the current open orders.
14011401
1402+
Parameters
1403+
----------
1404+
identifiers : list of str
1405+
A list of order identifiers to filter the orders by. If None, returns all tracked orders for the strategy.
1406+
14021407
Returns
14031408
-------
14041409
list of Order objects
@@ -1423,7 +1428,11 @@ def get_orders(self):
14231428
>>> self.cancel_order(order)
14241429
14251430
"""
1426-
return self.broker.get_tracked_orders(self.name)
1431+
all_orders = self.broker.get_tracked_orders(self.name)
1432+
if identifiers:
1433+
filtered_orders = [order for order in all_orders if order.identifier in identifiers]
1434+
return filtered_orders
1435+
return all_orders
14271436

14281437
def get_tracked_assets(self):
14291438
"""Get the list of assets for positions

lumibot/strategies/strategy_executor.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,17 @@ def sync_broker(self):
344344
for order in orders_broker:
345345
# Check against existing orders.
346346
order_lumi = [ord_lumi for ord_lumi in orders_lumi if ord_lumi.identifier == order.identifier]
347-
order_lumi = order_lumi[0] if len(order_lumi) > 0 else None
347+
if len(order_lumi) > 1:
348+
self.strategy.logger.warning(
349+
f"Multiple orders found in lumibot with the same identifier {order.identifier}. "
350+
f"This should not happen and indicates a bug in the order tracking. This is manifesting as "
351+
f"a race condition with ProjectX where a 'new' order event is being added along with a 'fill' "
352+
f"and the 'new' queue never gets cleared causing duplicate orders to be added to lumibot. "
353+
f"Orders: {order_lumi}"
354+
)
355+
order_lumi = self.broker._clean_order_trackers(order)
356+
else:
357+
order_lumi = order_lumi[0] if len(order_lumi) > 0 else None
348358

349359
if order_lumi:
350360
# Compare the orders.
@@ -427,6 +437,9 @@ def sync_broker(self):
427437
self.broker._process_partially_filled_order(order, order.avg_fill_price, order.quantity)
428438
elif order.status == Order.OrderStatus.NEW:
429439
self.broker._process_new_order(order)
440+
elif order.status == Order.OrderStatus.ERROR:
441+
self.broker._process_new_order(order)
442+
self.broker._process_error_order(order, order.error_message)
430443
else:
431444
# Add to order in lumibot.
432445
self.broker._process_new_order(order)
@@ -462,7 +475,7 @@ def sync_broker(self):
462475
continue
463476

464477
# Check if it's a market order that might have filled instantly
465-
if order_lumi.order_type and order_lumi.order_type.lower() == "market":
478+
if order_lumi.order_type and order_lumi.order_type == Order.OrderType.MARKET:
466479
self.strategy.logger.info(
467480
f"Market order {order_lumi} (id={order_lumi.identifier}) not found in broker, "
468481
f"likely filled instantly - skipping cancel"

lumibot/tools/lumibot_logger.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -622,7 +622,7 @@ def update_strategy_name(self, new_strategy_name: str):
622622
self.extra = extra_dict
623623

624624

625-
def _ensure_handlers_configured():
625+
def _ensure_handlers_configured(is_backtest=False):
626626
"""
627627
Ensure that the root logger has the appropriate handlers configured.
628628
This is called once globally to set up consistent formatting, but we also
@@ -636,6 +636,14 @@ def _ensure_handlers_configured():
636636
- LUMIBOT_ERROR_CSV_PATH: Path for CSV error log file (default: logs/errors.csv)
637637
- BACKTESTING_QUIET_LOGS: Enable quiet logs for backtesting (true/false)
638638
- LUMIWEALTH_API_KEY: API key for Lumiwealth/Botspot error reporting (when set, enables automatic error reporting)
639+
640+
Parameters
641+
----------
642+
is_backtest : bool, optional
643+
Whether we are in a backtesting (default False). This can also be determined from the IS_BACKTESTING
644+
environment variable, but is provided here as an optional parameter for cases
645+
where we might want to explicitly control this behavior without relying on environment variables.
646+
639647
"""
640648
global _handlers_configured, _BACKTESTING_QUIET_LOGS_ENABLED
641649

@@ -646,7 +654,7 @@ def _ensure_handlers_configured():
646654
except AttributeError:
647655
log_level = logging.INFO
648656

649-
is_backtesting = os.environ.get("IS_BACKTESTING", "").lower() == "true"
657+
is_backtesting = is_backtest or os.environ.get("IS_BACKTESTING", "").lower() == "true"
650658

651659
# Determine the effective file (root) log level and console level
652660
if is_backtesting:
@@ -929,7 +937,7 @@ def set_console_log_level(level: str):
929937
f"DEBUG, INFO, WARNING, ERROR, CRITICAL")
930938

931939

932-
def add_file_handler(file_path: str, level: str = 'INFO'):
940+
def add_file_handler(file_path: str, level: str = 'INFO', is_backtest: bool = False):
933941
"""
934942
Add a file handler to the root logger to also log to a file.
935943
@@ -939,13 +947,16 @@ def add_file_handler(file_path: str, level: str = 'INFO'):
939947
Path to the log file.
940948
level : str, optional
941949
Log level for the file handler. Defaults to 'INFO'.
950+
is_backtest : bool, optional
951+
Whether this is for backtesting (default False). This will ensure that the file handler respects
952+
the backtesting quiet logs/console setting if applicable.
942953
943954
Examples
944955
--------
945956
>>> from lumibot.tools.lumibot_logger import add_file_handler
946957
>>> add_file_handler('/path/to/lumibot.log', 'DEBUG')
947958
"""
948-
_ensure_handlers_configured()
959+
_ensure_handlers_configured(is_backtest=is_backtest)
949960

950961
try:
951962
file_level = getattr(logging, level.upper())

lumibot/tools/projectx_helpers.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -524,7 +524,32 @@ def order_cancel(self, account_id: int, order_id: int) -> dict:
524524
except requests.exceptions.RequestException as e:
525525
self.logger.error(f"Error cancelling order: {e}")
526526
return {"success": False, "error": str(e)}
527-
527+
528+
def order_modify(self, account_id: int, order_id: int, size: int = None, limit_price: float = None,
529+
stop_price: float = None, trail_price: float = None) -> dict:
530+
"""Modify an existing order"""
531+
url = f"{self.base_url}api/order/modify"
532+
payload = {
533+
"accountId": account_id,
534+
"orderId": order_id,
535+
"size": size,
536+
"limitPrice": limit_price,
537+
"stopPrice": stop_price,
538+
"trailPrice": trail_price,
539+
}
540+
# Remove None values to avoid API model binding issues
541+
payload = {k: v for k, v in payload.items() if v is not None}
542+
try:
543+
response = requests.post(url, headers=self.headers, json=payload, timeout=10)
544+
try:
545+
data = response.json()
546+
except ValueError:
547+
data = {"success": False, "error": f"Non-JSON response {response.status_code}"}
548+
return data
549+
except requests.exceptions.RequestException as e:
550+
self.logger.error(f"Error modifying order: {e}")
551+
return {"success": False, "error": str(e)}
552+
528553
def contract_search(self, search_text: str, live: bool = False) -> dict:
529554
"""Search for contracts by name"""
530555
url = f"{self.base_url}api/contract/search"
@@ -691,7 +716,7 @@ def __init__(self, config: dict):
691716
self._positions_cache_time = 0
692717
self._orders_cache = None
693718
self._orders_cache_time = 0
694-
self._cache_ttl = 30 # 30 seconds cache
719+
self._cache_ttl = 5 # 30 seconds cache
695720

696721
def get_accounts(self) -> List[Dict]:
697722
"""Get list of available accounts"""
@@ -833,9 +858,20 @@ def cancel_order(self, account_id: int, order_id: str) -> bool:
833858

834859
def order_modify(self, account_id: int, order_id: int, size: int = None,
835860
limit_price: float = None, stop_price: float = None, trail_price: float = None) -> Dict:
836-
"""Modify an existing order - Not supported by ProjectX API"""
837-
# ProjectX doesn't support order modification - need to cancel and re-place
838-
return {"success": False, "error": "Order modification not supported by ProjectX API"}
861+
"""Modify an existing order - definitely supported by ProjectX API"""
862+
# ProjectX certainly does support order modification
863+
response = self.api.order_modify(
864+
account_id=account_id,
865+
order_id=order_id,
866+
size=size,
867+
limit_price=limit_price,
868+
stop_price=stop_price,
869+
trail_price=trail_price
870+
)
871+
if response and response.get("success"):
872+
return response
873+
else:
874+
raise Exception(f"Failed to modify order: {response}")
839875

840876
def get_historical_data(self, contract_id: str, start_time: str, end_time: str,
841877
timeframe: str = "1minute") -> pd.DataFrame:

lumibot/traders/trader.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pathlib import Path
88
from typing import Optional
99

10-
from lumibot.tools.lumibot_logger import get_logger
10+
from lumibot.tools.lumibot_logger import get_logger, set_console_log_level
1111

1212
# Overloading time.sleep to warn users against using it
1313

@@ -330,6 +330,7 @@ def _set_logger(self):
330330
set_log_level("ERROR")
331331
else:
332332
set_log_level("INFO")
333+
set_console_log_level("ERROR") # Set console log level to ERROR while keeping file logs at INFO
333334
# When quiet_logs=False, allow INFO logs to console (respects BACKTESTING_QUIET_LOGS)
334335
else:
335336
# Live trades should always have full logging for both console and file
@@ -347,7 +348,8 @@ def _set_logger(self):
347348

348349
# Setting file logging if specified
349350
if self.logfile:
350-
add_file_handler(str(self.logfile), level="DEBUG" if self.debug else "INFO")
351+
add_file_handler(str(self.logfile), level="DEBUG" if self.debug else "INFO",
352+
is_backtest=self.is_backtest_broker)
351353

352354
# Disable Interactive Brokers logs
353355
for log_name, log_obj in logging.Logger.manager.loggerDict.items():

0 commit comments

Comments
 (0)