lumibot 4.2.9__py3-none-any.whl → 4.2.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of lumibot might be problematic. Click here for more details.

lumibot/brokers/broker.py CHANGED
@@ -1415,6 +1415,18 @@ class Broker(ABC):
1415
1415
  def cancel_open_orders(self, strategy):
1416
1416
  """cancel all open orders for a given strategy"""
1417
1417
  orders = [o for o in self.get_tracked_orders(strategy) if o.is_active()]
1418
+ order_ids = [
1419
+ getattr(order, "identifier", None)
1420
+ or getattr(order, "id", None)
1421
+ or getattr(order, "order_id", None)
1422
+ for order in orders
1423
+ ]
1424
+ self.logger.info(
1425
+ "cancel_open_orders(strategy=%s) -> active=%d ids=%s",
1426
+ strategy,
1427
+ len(orders),
1428
+ order_ids,
1429
+ )
1418
1430
  self.cancel_orders(orders)
1419
1431
 
1420
1432
  def wait_orders_clear(self, strategy, max_loop=5):
@@ -1487,10 +1499,31 @@ class Broker(ABC):
1487
1499
  """
1488
1500
  pos = self.get_tracked_position(strategy_name, asset)
1489
1501
  if pos and pos.quantity != 0:
1502
+ self.logger.info(
1503
+ "close_position(strategy=%s, asset=%s, fraction=%s) -> qty=%s",
1504
+ strategy_name,
1505
+ getattr(asset, "symbol", asset),
1506
+ fraction,
1507
+ pos.quantity,
1508
+ )
1490
1509
  order = pos.get_selling_order(quote_asset=self.quote_assets and next(iter(self.quote_assets)))
1491
1510
  if fraction != 1.00:
1492
1511
  order.quantity = order.quantity * fraction
1512
+ order_id = getattr(order, "identifier", None) or getattr(order, "id", None) or getattr(order, "order_id", None)
1513
+ self.logger.info(
1514
+ "close_position(strategy=%s) submitting order %s qty=%s side=%s type=%s",
1515
+ strategy_name,
1516
+ order_id,
1517
+ getattr(order, "quantity", None),
1518
+ getattr(order, "side", None),
1519
+ getattr(order, "order_type", None),
1520
+ )
1493
1521
  return self.submit_order(order)
1522
+ self.logger.info(
1523
+ "close_position(strategy=%s, asset=%s) -> no tracked position or zero quantity",
1524
+ strategy_name,
1525
+ getattr(asset, "symbol", asset),
1526
+ )
1494
1527
  return None
1495
1528
 
1496
1529
  # =========Subscribers/Strategies functions==============
@@ -2,8 +2,9 @@ import random
2
2
  import re
3
3
  import threading
4
4
  import time
5
+ import traceback
5
6
  from collections import deque
6
- from datetime import datetime
7
+ from datetime import datetime, timezone
7
8
  from typing import Optional, Union
8
9
 
9
10
  import requests
@@ -12,6 +13,7 @@ from termcolor import colored
12
13
  from .broker import Broker
13
14
  from lumibot.data_sources import TradovateData
14
15
  from lumibot.entities import Asset, Order, Position
16
+ from lumibot.trading_builtins import PollingStream
15
17
 
16
18
  # Set up module-specific logger for enhanced logging
17
19
  from lumibot.tools.lumibot_logger import get_logger
@@ -31,6 +33,7 @@ class Tradovate(Broker):
31
33
  Tradovate broker that implements connection to the Tradovate API.
32
34
  """
33
35
  NAME = "Tradovate"
36
+ POLL_EVENT = PollingStream.POLL_EVENT
34
37
 
35
38
  def __init__(self, config=None, data_source=None):
36
39
  if config is None:
@@ -45,6 +48,10 @@ class Tradovate(Broker):
45
48
  self.app_version = config.get("APP_VERSION", "1.0")
46
49
  self.cid = config.get("CID")
47
50
  self.sec = config.get("SECRET")
51
+ self.polling_interval = float(config.get("POLLING_INTERVAL", 5.0))
52
+ self._seen_fill_ids: set[int] = set()
53
+ self._fill_bootstrap_cutoff = datetime.now(timezone.utc)
54
+ self._active_broker_identifiers: Optional[set[str]] = None
48
55
 
49
56
  # Configure lightweight in-process rate limiter for REST calls
50
57
  self._rate_limit_per_minute = max(int(config.get("RATE_LIMIT_PER_MINUTE", 60)), 1)
@@ -520,9 +527,10 @@ class Tradovate(Broker):
520
527
  original_exception=final_exception)
521
528
 
522
529
  raise TradovateAPIError("Failed to retrieve account financials")
530
+
523
531
  def _get_stream_object(self):
524
- logger.info(colored("Method '_get_stream_object' is not yet implemented.", "yellow"))
525
- return None # Return None as a placeholder
532
+ """Return a polling stream to monitor Tradovate orders."""
533
+ return PollingStream(self.polling_interval)
526
534
 
527
535
  def check_token_expiry(self):
528
536
  """
@@ -705,12 +713,482 @@ class Tradovate(Broker):
705
713
  original_exception=e)
706
714
 
707
715
  def _register_stream_events(self):
708
- logger.error(colored("Method '_register_stream_events' is not yet implemented.", "red"))
709
- return None
716
+ """Register polling callbacks that mirror the standard lifecycle pipeline."""
717
+ stream = getattr(self, "stream", None)
718
+ if stream is None:
719
+ return
720
+
721
+ broker = self
722
+
723
+ @stream.add_action(self.POLL_EVENT)
724
+ def on_trade_event_poll():
725
+ try:
726
+ self.do_polling()
727
+ except Exception as exc: # pragma: no cover - defensive logging
728
+ logger.exception("Tradovate polling failure: %s", exc)
729
+
730
+ @stream.add_action(self.NEW_ORDER)
731
+ def on_trade_event_new(order):
732
+ logger.info(f"Tradovate processing NEW order event: {order}")
733
+ try:
734
+ broker._process_trade_event(order, broker.NEW_ORDER)
735
+ except Exception: # pragma: no cover
736
+ logger.error(traceback.format_exc())
737
+
738
+ @stream.add_action(self.FILLED_ORDER)
739
+ def on_trade_event_fill(order, price, filled_quantity):
740
+ logger.info(
741
+ f"Tradovate processing FILLED event: {order} price={price} qty={filled_quantity}"
742
+ )
743
+ try:
744
+ broker._process_trade_event(
745
+ order,
746
+ broker.FILLED_ORDER,
747
+ price=price,
748
+ filled_quantity=filled_quantity,
749
+ multiplier=order.asset.multiplier if order.asset else 1,
750
+ )
751
+ except Exception: # pragma: no cover
752
+ logger.error(traceback.format_exc())
753
+
754
+ @stream.add_action(self.PARTIALLY_FILLED_ORDER)
755
+ def on_trade_event_partial(order, price, filled_quantity):
756
+ logger.info(
757
+ f"Tradovate processing PARTIAL event: {order} price={price} qty={filled_quantity}"
758
+ )
759
+ try:
760
+ broker._process_trade_event(
761
+ order,
762
+ broker.PARTIALLY_FILLED_ORDER,
763
+ price=price,
764
+ filled_quantity=filled_quantity,
765
+ multiplier=order.asset.multiplier if order.asset else 1,
766
+ )
767
+ except Exception: # pragma: no cover
768
+ logger.error(traceback.format_exc())
769
+
770
+ @stream.add_action(self.CANCELED_ORDER)
771
+ def on_trade_event_cancel(order):
772
+ logger.info(f"Tradovate processing CANCEL event: {order}")
773
+ try:
774
+ broker._process_trade_event(order, broker.CANCELED_ORDER)
775
+ except Exception: # pragma: no cover
776
+ logger.error(traceback.format_exc())
777
+
778
+ @stream.add_action(self.ERROR_ORDER)
779
+ def on_trade_event_error(order, error_msg=None):
780
+ logger.error(f"Tradovate processing ERROR event: {order} msg={error_msg}")
781
+ try:
782
+ broker._process_trade_event(order, broker.ERROR_ORDER, error=error_msg)
783
+ except Exception: # pragma: no cover
784
+ logger.error(traceback.format_exc())
710
785
 
711
786
  def _run_stream(self):
712
- logger.error(colored("Method '_run_stream' is not yet implemented.", "red"))
713
- return None
787
+ """Start the polling loop and mark the connection as established."""
788
+ self._stream_established()
789
+ if getattr(self, "stream", None) is None:
790
+ return
791
+ try:
792
+ self.stream._run()
793
+ except Exception as exc: # pragma: no cover - defensive logging
794
+ logger.exception("Tradovate polling stream terminated unexpectedly: %s", exc)
795
+
796
+ # ------------------------------------------------------------------
797
+ # Polling helpers
798
+ # ------------------------------------------------------------------
799
+ def _extract_fill_details(self, raw_order: dict, order: Order) -> tuple[Optional[float], Optional[float]]:
800
+ """Attempt to derive fill price and quantity from a Tradovate order payload."""
801
+
802
+ def _normalize_number(value):
803
+ try:
804
+ if value in (None, "", "null"):
805
+ return None
806
+ numeric = float(value)
807
+ if numeric == 0:
808
+ return None
809
+ return numeric
810
+ except (TypeError, ValueError):
811
+ return None
812
+
813
+ def _to_float(value):
814
+ try:
815
+ if value in (None, "", "null"):
816
+ return None
817
+ return float(value)
818
+ except (TypeError, ValueError):
819
+ return None
820
+
821
+ price_candidates = [
822
+ raw_order.get("avgFillPrice"),
823
+ raw_order.get("filledPrice"),
824
+ raw_order.get("tradePrice"),
825
+ raw_order.get("price"),
826
+ raw_order.get("stopPrice"),
827
+ raw_order.get("lastPrice"),
828
+ getattr(order, "avg_fill_price", None),
829
+ getattr(order, "limit_price", None),
830
+ ]
831
+ price = next((val for val in (_to_float(candidate) for candidate in price_candidates) if val is not None), None)
832
+
833
+ quantity_candidates = [
834
+ raw_order.get("filledQuantity"),
835
+ raw_order.get("filledQty"),
836
+ raw_order.get("execQuantity"),
837
+ raw_order.get("tradeQuantity"),
838
+ raw_order.get("quantity"),
839
+ getattr(order, "quantity", None),
840
+ ]
841
+
842
+ if getattr(order, "child_orders", None):
843
+ for child in order.child_orders:
844
+ quantity_candidates.append(getattr(child, "quantity", None))
845
+ price_candidates.append(getattr(child, "avg_fill_price", None))
846
+ quantity = next(
847
+ (val for val in (_to_float(candidate) for candidate in quantity_candidates) if val is not None),
848
+ None,
849
+ )
850
+
851
+ order_identifier = raw_order.get("id") or getattr(order, "identifier", None)
852
+ needs_fill_lookup = (
853
+ order_identifier
854
+ and (
855
+ price is None
856
+ or quantity in (None, 0, 0.0)
857
+ )
858
+ )
859
+ if needs_fill_lookup:
860
+ fill_price, fill_qty = self._fetch_recent_fill_details(order_identifier)
861
+ if fill_qty is not None:
862
+ quantity = _normalize_number(fill_qty)
863
+ if price is None and fill_price is not None:
864
+ price = _normalize_number(fill_price)
865
+
866
+ return price, quantity
867
+
868
+ def _fetch_recent_fill_details(self, order_identifier) -> tuple[Optional[float], Optional[float]]:
869
+ """Fallback to /fill/list when Tradovate omits fill price/quantity in order payloads."""
870
+ if not order_identifier:
871
+ return None, None
872
+
873
+ try:
874
+ params = {"accountId": self.account_id, "orderId": order_identifier}
875
+ response = self._request(
876
+ "GET",
877
+ f"{self.trading_api_url}/fill/list",
878
+ params=params,
879
+ headers=self._get_headers(),
880
+ )
881
+ response.raise_for_status()
882
+ fills = response.json()
883
+ except requests.exceptions.RequestException as exc:
884
+ logger.debug("Tradovate fill lookup failed for order %s: %s", order_identifier, exc)
885
+ return None, None
886
+
887
+ if not isinstance(fills, list):
888
+ return None, None
889
+
890
+ new_fills = []
891
+ for fill in fills:
892
+ if str(fill.get("orderId")) != str(order_identifier):
893
+ continue
894
+
895
+ fill_id = fill.get("id")
896
+ if fill_id in self._seen_fill_ids:
897
+ continue
898
+
899
+ timestamp_str = fill.get("timestamp")
900
+ fill_dt = None
901
+ if timestamp_str:
902
+ try:
903
+ fill_dt = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
904
+ except ValueError:
905
+ fill_dt = None
906
+
907
+ if fill_dt and fill_dt < self._fill_bootstrap_cutoff:
908
+ self._seen_fill_ids.add(fill_id)
909
+ continue
910
+
911
+ qty = fill.get("qty")
912
+ price = fill.get("price")
913
+ if qty in (None, "", "null"):
914
+ self._seen_fill_ids.add(fill_id)
915
+ continue
916
+
917
+ try:
918
+ qty_val = float(qty)
919
+ except (TypeError, ValueError):
920
+ self._seen_fill_ids.add(fill_id)
921
+ continue
922
+
923
+ if qty_val == 0:
924
+ self._seen_fill_ids.add(fill_id)
925
+ continue
926
+
927
+ price_val = None
928
+ try:
929
+ price_val = float(price) if price is not None else None
930
+ except (TypeError, ValueError):
931
+ price_val = None
932
+
933
+ new_fills.append((fill_id, price_val, qty_val))
934
+ self._seen_fill_ids.add(fill_id)
935
+
936
+ if not new_fills:
937
+ return None, None
938
+
939
+ total_qty = sum(qty for _, _, qty in new_fills)
940
+ if total_qty <= 0:
941
+ return None, None
942
+
943
+ weighted_price = sum((price or 0.0) * qty for _, price, qty in new_fills)
944
+ avg_price = weighted_price / total_qty if weighted_price else None
945
+ return avg_price, total_qty
946
+
947
+ def do_polling(self):
948
+ """Poll Tradovate REST endpoints to keep order state synchronized."""
949
+ # Sync positions so position lookups remain accurate.
950
+ try:
951
+ self.sync_positions(None)
952
+ except Exception as exc: # pragma: no cover - defensive logging
953
+ logger.debug("Tradovate position sync failed during polling: %s", exc)
954
+
955
+ try:
956
+ raw_orders = self._pull_broker_all_orders() or []
957
+ except TradovateAPIError as exc:
958
+ logger.error(colored(f"Tradovate polling: failed to retrieve orders ({exc})", "red"))
959
+ return
960
+ except Exception as exc: # pragma: no cover
961
+ logger.exception("Tradovate polling: unexpected error retrieving orders: %s", exc)
962
+ return
963
+
964
+ stored_orders = {
965
+ order.identifier: order
966
+ for order in self.get_all_orders()
967
+ if getattr(order, "identifier", None) is not None
968
+ }
969
+ seen_identifiers: set[str] = set()
970
+ active_identifiers: set[str] = set()
971
+
972
+ for raw_order in raw_orders:
973
+ try:
974
+ parsed_order = self._parse_broker_order(
975
+ raw_order,
976
+ strategy_name=self._strategy_name or (self.NAME if isinstance(self.NAME, str) else "Tradovate"),
977
+ )
978
+ except Exception as exc: # pragma: no cover
979
+ logger.debug("Tradovate polling: unable to parse order payload: %s", exc)
980
+ continue
981
+
982
+ if parsed_order is None or not parsed_order.identifier:
983
+ continue
984
+
985
+ identifier = str(parsed_order.identifier)
986
+ seen_identifiers.add(identifier)
987
+ stored_order = stored_orders.get(parsed_order.identifier)
988
+
989
+ status = parsed_order.status
990
+ status_str = status.value if isinstance(status, Order.OrderStatus) else str(status).lower()
991
+ is_active_status = (
992
+ status in {Order.OrderStatus.NEW, Order.OrderStatus.OPEN}
993
+ or status_str in {"new", "submitted", "open", "working", "pending"}
994
+ )
995
+ if is_active_status:
996
+ active_identifiers.add(identifier)
997
+
998
+ if stored_order is None:
999
+ logger.debug(f"Tradovate polling: discovered new order {identifier} (status={parsed_order.status})")
1000
+ if is_active_status:
1001
+ self.stream.dispatch(self.NEW_ORDER, order=parsed_order)
1002
+ price, quantity = self._extract_fill_details(raw_order, parsed_order)
1003
+
1004
+ if (status == Order.OrderStatus.FILLED or status_str == "filled") and price is not None and quantity is not None:
1005
+ self.stream.dispatch(self.FILLED_ORDER, order=parsed_order, price=price, filled_quantity=quantity)
1006
+ elif (status == Order.OrderStatus.PARTIALLY_FILLED or status_str in {"partialfill", "partial_fill", "partially_filled"}) and price is not None and quantity is not None:
1007
+ self.stream.dispatch(
1008
+ self.PARTIALLY_FILLED_ORDER,
1009
+ order=parsed_order,
1010
+ price=price,
1011
+ filled_quantity=quantity,
1012
+ )
1013
+ elif status == Order.OrderStatus.CANCELED or status_str in {"canceled", "cancelled", "cancel"}:
1014
+ self.stream.dispatch(self.CANCELED_ORDER, order=parsed_order)
1015
+ elif status == Order.OrderStatus.ERROR or status_str in {"error", "rejected"}:
1016
+ error_msg = raw_order.get("failureText") or raw_order.get("failureReason")
1017
+ self.stream.dispatch(self.ERROR_ORDER, order=parsed_order, error_msg=error_msg)
1018
+ continue
1019
+
1020
+ # Refresh stored order attributes for downstream consumers.
1021
+ if parsed_order.limit_price is not None:
1022
+ stored_order.limit_price = parsed_order.limit_price
1023
+ if parsed_order.stop_price is not None:
1024
+ stored_order.stop_price = parsed_order.stop_price
1025
+ if parsed_order.avg_fill_price:
1026
+ stored_order.avg_fill_price = parsed_order.avg_fill_price
1027
+ if parsed_order.quantity:
1028
+ stored_order.quantity = parsed_order.quantity
1029
+
1030
+ if not parsed_order.equivalent_status(stored_order):
1031
+ price, quantity = self._extract_fill_details(raw_order, parsed_order)
1032
+
1033
+ if status == Order.OrderStatus.FILLED or status_str == "filled":
1034
+ if price is not None and quantity is not None:
1035
+ self.stream.dispatch(
1036
+ self.FILLED_ORDER,
1037
+ order=stored_order,
1038
+ price=price,
1039
+ filled_quantity=quantity,
1040
+ )
1041
+ elif status == Order.OrderStatus.PARTIALLY_FILLED or status_str in {"partialfill", "partial_fill", "partially_filled"}:
1042
+ if price is not None and quantity is not None:
1043
+ self.stream.dispatch(
1044
+ self.PARTIALLY_FILLED_ORDER,
1045
+ order=stored_order,
1046
+ price=price,
1047
+ filled_quantity=quantity,
1048
+ )
1049
+ elif status == Order.OrderStatus.CANCELED or status_str in {"canceled", "cancelled", "cancel"}:
1050
+ self.stream.dispatch(self.CANCELED_ORDER, order=stored_order)
1051
+ elif status == Order.OrderStatus.ERROR or status_str in {"error", "rejected"}:
1052
+ error_msg = raw_order.get("failureText") or raw_order.get("failureReason")
1053
+ self.stream.dispatch(self.ERROR_ORDER, order=stored_order, error_msg=error_msg)
1054
+ else:
1055
+ if is_active_status:
1056
+ self.stream.dispatch(self.NEW_ORDER, order=stored_order)
1057
+
1058
+ # Any active order missing from the broker response likely completed; reconcile as canceled.
1059
+ for order in list(self.get_all_orders()):
1060
+ identifier = getattr(order, "identifier", None)
1061
+ if not identifier:
1062
+ continue
1063
+ if str(identifier) not in seen_identifiers and order.is_active():
1064
+ fill_price, fill_qty = self._fetch_recent_fill_details(identifier)
1065
+ if fill_qty:
1066
+ if fill_price is None:
1067
+ fill_price = getattr(order, "avg_fill_price", None)
1068
+ if fill_price is None:
1069
+ try:
1070
+ quote = self.get_quote(order.asset)
1071
+ fill_price = getattr(quote, "last", None)
1072
+ except Exception: # pragma: no cover - defensive
1073
+ fill_price = None
1074
+
1075
+ if fill_price is not None:
1076
+ self.stream.dispatch(
1077
+ self.FILLED_ORDER,
1078
+ order=order,
1079
+ price=float(fill_price),
1080
+ filled_quantity=float(fill_qty),
1081
+ )
1082
+ continue
1083
+
1084
+ logger.debug(
1085
+ f"Tradovate polling: order {identifier} missing from broker response; dispatching CANCEL to reconcile."
1086
+ )
1087
+ self.stream.dispatch(self.CANCELED_ORDER, order=order)
1088
+
1089
+ if self._first_iteration:
1090
+ self._first_iteration = False
1091
+
1092
+ self._active_broker_identifiers = active_identifiers
1093
+
1094
+ # ------------------------------------------------------------------
1095
+ # Order management overrides
1096
+ # ------------------------------------------------------------------
1097
+ def _mark_order_inactive_locally(self, order: Order, status: str):
1098
+ """Update internal tracking lists without dispatching noisy lifecycle logs."""
1099
+ identifier = getattr(order, "identifier", None)
1100
+ if not identifier:
1101
+ return
1102
+
1103
+ safe_lists = (
1104
+ self._new_orders,
1105
+ self._unprocessed_orders,
1106
+ self._partially_filled_orders,
1107
+ self._placeholder_orders,
1108
+ )
1109
+ for safe_list in safe_lists:
1110
+ safe_list.remove(identifier, key="identifier")
1111
+
1112
+ if status == self.CANCELED_ORDER:
1113
+ self._canceled_orders.remove(identifier, key="identifier")
1114
+ order.status = self.CANCELED_ORDER
1115
+ order.set_canceled()
1116
+ self._canceled_orders.append(order)
1117
+ elif status == self.FILLED_ORDER:
1118
+ self._filled_orders.remove(identifier, key="identifier")
1119
+ order.status = self.FILLED_ORDER
1120
+ order.set_filled()
1121
+ self._filled_orders.append(order)
1122
+ else:
1123
+ order.status = status
1124
+
1125
+ def cancel_open_orders(self, strategy):
1126
+ """Cancel only the orders that are still active on Tradovate; prune the rest silently."""
1127
+ tracked_orders = [order for order in self.get_tracked_orders(strategy) if order.is_active()]
1128
+ if not tracked_orders:
1129
+ self.logger.info("cancel_open_orders(strategy=%s) -> no active orders tracked", strategy)
1130
+ return
1131
+
1132
+ active_ids = getattr(self, "_active_broker_identifiers", None)
1133
+ if active_ids is None:
1134
+ active_ids = self._refresh_active_identifiers_snapshot()
1135
+ orders_to_cancel: list[Order] = []
1136
+ stale_count = 0
1137
+
1138
+ for order in tracked_orders:
1139
+ identifier = getattr(order, "identifier", None)
1140
+ identifier_str = str(identifier) if identifier is not None else None
1141
+ if identifier_str is not None and identifier_str not in active_ids:
1142
+ stale_count += 1
1143
+ self._mark_order_inactive_locally(order, self.CANCELED_ORDER)
1144
+ continue
1145
+ orders_to_cancel.append(order)
1146
+
1147
+ if stale_count:
1148
+ self.logger.debug(
1149
+ "Tradovate cancel_open_orders removed %d stale local orders not present at broker for strategy=%s",
1150
+ stale_count,
1151
+ strategy,
1152
+ )
1153
+
1154
+ if not orders_to_cancel:
1155
+ self.logger.info("cancel_open_orders(strategy=%s) -> nothing to cancel after pruning", strategy)
1156
+ return
1157
+
1158
+ order_ids = [
1159
+ getattr(order, "identifier", None)
1160
+ or getattr(order, "id", None)
1161
+ or getattr(order, "order_id", None)
1162
+ for order in orders_to_cancel
1163
+ ]
1164
+ self.logger.info(
1165
+ "cancel_open_orders(strategy=%s) -> active=%d ids=%s",
1166
+ strategy,
1167
+ len(orders_to_cancel),
1168
+ order_ids,
1169
+ )
1170
+ self.cancel_orders(orders_to_cancel)
1171
+
1172
+ def _refresh_active_identifiers_snapshot(self) -> set[str]:
1173
+ """Retrieve latest open orders from Tradovate to seed the active-id cache."""
1174
+ active_ids: set[str] = set()
1175
+ try:
1176
+ payloads = self._pull_broker_all_orders() or []
1177
+ except Exception as exc: # pragma: no cover - defensive logging
1178
+ logger.debug("Tradovate active id refresh failed: %s", exc)
1179
+ self._active_broker_identifiers = active_ids
1180
+ return active_ids
1181
+
1182
+ for payload in payloads:
1183
+ order_id = payload.get("id")
1184
+ if order_id is None:
1185
+ continue
1186
+ status = str(payload.get("ordStatus", "")).lower()
1187
+ if status in {"working", "pending", "submitted", "new", "open"}:
1188
+ active_ids.add(str(order_id))
1189
+
1190
+ self._active_broker_identifiers = active_ids
1191
+ return active_ids
714
1192
 
715
1193
  def _submit_order(self, order: Order) -> Order:
716
1194
  """
@@ -807,6 +1285,20 @@ class Tradovate(Broker):
807
1285
  # Order was successful
808
1286
  order.status = Order.OrderStatus.SUBMITTED
809
1287
  order.update_raw(data)
1288
+ order_id = (
1289
+ data.get("orderId")
1290
+ or data.get("id")
1291
+ or (data.get("order") or {}).get("id")
1292
+ or (data.get("orders") or {}).get("id")
1293
+ )
1294
+ if order_id is not None:
1295
+ order.set_identifier(str(order_id))
1296
+
1297
+ if hasattr(self, "_new_orders"):
1298
+ try:
1299
+ self._process_trade_event(order, self.NEW_ORDER)
1300
+ except Exception: # pragma: no cover - defensive
1301
+ logger.error(traceback.format_exc())
810
1302
  return order
811
1303
 
812
1304
  except requests.exceptions.RequestException as e:
@@ -815,9 +1307,63 @@ class Tradovate(Broker):
815
1307
  order.set_error(error_message)
816
1308
  return order
817
1309
 
818
- def cancel_order(self, order_id) -> None:
819
- logger.error(colored(f"Method 'cancel_order' for order_id {order_id} is not yet implemented.", "red"))
820
- return None
1310
+ def cancel_order(self, order) -> None:
1311
+ """Cancel an order at Tradovate and propagate lifecycle events."""
1312
+ target_order = None
1313
+ identifier = None
1314
+
1315
+ if isinstance(order, Order):
1316
+ target_order = order
1317
+ identifier = order.identifier
1318
+ else:
1319
+ identifier = order
1320
+
1321
+ if not identifier:
1322
+ raise ValueError("Order identifier is not set; unable to cancel order.")
1323
+
1324
+ try:
1325
+ order_id_value = int(identifier)
1326
+ except (TypeError, ValueError):
1327
+ order_id_value = identifier
1328
+
1329
+ payload = {
1330
+ "accountSpec": self.account_spec,
1331
+ "accountId": self.account_id,
1332
+ "orderId": order_id_value,
1333
+ }
1334
+ url = f"{self.trading_api_url}/order/cancelorder"
1335
+ headers = self._get_headers(with_content_type=True)
1336
+
1337
+ try:
1338
+ response = self._request("POST", url, json=payload, headers=headers)
1339
+ response.raise_for_status()
1340
+ except requests.exceptions.RequestException as exc:
1341
+ logger.error(
1342
+ colored(
1343
+ f"Tradovate cancel failed for order {identifier}: "
1344
+ f"{getattr(exc.response, 'status_code', None)} {getattr(exc.response, 'text', None)}",
1345
+ "red",
1346
+ )
1347
+ )
1348
+ raise TradovateAPIError(
1349
+ "Failed to cancel Tradovate order",
1350
+ status_code=getattr(exc.response, "status_code", None),
1351
+ response_text=getattr(exc.response, "text", None),
1352
+ original_exception=exc,
1353
+ ) from exc
1354
+
1355
+ if target_order is None:
1356
+ target_order = self.get_tracked_order(identifier, use_placeholders=True)
1357
+
1358
+ if target_order is not None and hasattr(self, "stream"):
1359
+ self.stream.dispatch(self.CANCELED_ORDER, order=target_order)
1360
+
1361
+ def _pull_all_orders(self, strategy_name, strategy_object) -> list[Order]:
1362
+ """Skip returning legacy orders during the initial sync to avoid duplicate NEW events."""
1363
+ if getattr(self, "_first_iteration", False):
1364
+ logger.debug("Tradovate initial order sync skipped to allow polling to reconcile legacy orders")
1365
+ return []
1366
+ return super()._pull_all_orders(strategy_name, strategy_object)
821
1367
 
822
1368
  def _modify_order(self, order: Order, limit_price: Union[float, None] = None,
823
1369
  stop_price: Union[float, None] = None):