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 +33 -0
- lumibot/brokers/tradovate.py +556 -10
- lumibot/data_sources/databento_data_polars.py +35 -34
- lumibot/entities/asset.py +11 -0
- lumibot/strategies/strategy.py +46 -1
- lumibot/tools/databento_helper.py +16 -0
- lumibot/tools/databento_helper_polars.py +41 -1
- lumibot/tools/futures_roll.py +71 -20
- lumibot/tools/thetadata_helper.py +4 -1
- {lumibot-4.2.9.dist-info → lumibot-4.2.10.dist-info}/METADATA +1 -1
- {lumibot-4.2.9.dist-info → lumibot-4.2.10.dist-info}/RECORD +18 -17
- tests/test_futures_roll.py +20 -0
- tests/test_strategy_close_position.py +83 -0
- tests/test_thetadata_helper.py +45 -1
- tests/test_tradovate.py +293 -0
- {lumibot-4.2.9.dist-info → lumibot-4.2.10.dist-info}/WHEEL +0 -0
- {lumibot-4.2.9.dist-info → lumibot-4.2.10.dist-info}/licenses/LICENSE +0 -0
- {lumibot-4.2.9.dist-info → lumibot-4.2.10.dist-info}/top_level.txt +0 -0
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==============
|
lumibot/brokers/tradovate.py
CHANGED
|
@@ -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
|
-
|
|
525
|
-
return
|
|
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
|
-
|
|
709
|
-
|
|
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
|
-
|
|
713
|
-
|
|
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,
|
|
819
|
-
|
|
820
|
-
|
|
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):
|