tradingapi 0.3.10__tar.gz → 0.3.11__tar.gz

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.
Files changed (35) hide show
  1. {tradingapi-0.3.10 → tradingapi-0.3.11}/PKG-INFO +1 -1
  2. {tradingapi-0.3.10 → tradingapi-0.3.11}/pyproject.toml +1 -1
  3. tradingapi-0.3.11/tests/test_fivepaisa_stream_reconnect.py +135 -0
  4. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/fivepaisa.py +86 -43
  5. tradingapi-0.3.11/tradingapi/ib.py +864 -0
  6. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/market_data_exchanges.py +1 -0
  7. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/utils.py +34 -1
  8. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi.egg-info/PKG-INFO +1 -1
  9. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi.egg-info/SOURCES.txt +2 -0
  10. {tradingapi-0.3.10 → tradingapi-0.3.11}/README.md +0 -0
  11. {tradingapi-0.3.10 → tradingapi-0.3.11}/setup.cfg +0 -0
  12. {tradingapi-0.3.10 → tradingapi-0.3.11}/tests/test_broker_side_terminal_order.py +0 -0
  13. {tradingapi-0.3.10 → tradingapi-0.3.11}/tests/test_calculate_delta_realtime_quotes.py +0 -0
  14. {tradingapi-0.3.10 → tradingapi-0.3.11}/tests/test_find_option_with_delta.py +0 -0
  15. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/__init__.py +0 -0
  16. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/allocation.py +0 -0
  17. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/attribution.py +0 -0
  18. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/broker_base.py +0 -0
  19. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/config/commissions_20241216.yaml +0 -0
  20. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/config/config_sample.yaml +0 -0
  21. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/config.py +0 -0
  22. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/dhan.py +0 -0
  23. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/error_handling.py +0 -0
  24. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/exceptions.py +0 -0
  25. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/flattrade.py +0 -0
  26. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/globals.py +0 -0
  27. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/icicidirect.py +0 -0
  28. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/icicidirect_generate_session.py +0 -0
  29. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/proxy_utils.py +0 -0
  30. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/shoonya.py +0 -0
  31. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi/span.py +0 -0
  32. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi.egg-info/dependency_links.txt +0 -0
  33. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi.egg-info/entry_points.txt +0 -0
  34. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi.egg-info/requires.txt +0 -0
  35. {tradingapi-0.3.10 → tradingapi-0.3.11}/tradingapi.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tradingapi
3
- Version: 0.3.10
3
+ Version: 0.3.11
4
4
  Summary: Trade integration with brokers
5
5
  Author-email: Pankaj Sharma <sharma.pankaj.kumar@gmail.com>
6
6
  License-Expression: MIT
@@ -28,7 +28,7 @@ packages = ["tradingapi"]
28
28
 
29
29
  [project]
30
30
  name = "tradingapi"
31
- version = "0.3.10"
31
+ version = "0.3.11"
32
32
  description = "Trade integration with brokers"
33
33
  readme = "README.md"
34
34
  license = "MIT"
@@ -0,0 +1,135 @@
1
+ import json
2
+ import sys
3
+ import threading
4
+ import time
5
+ from unittest.mock import patch
6
+
7
+ sys.path.insert(0, "/home/psharma/onedrive/code/tradingapi2")
8
+
9
+ from tradingapi.broker_base import Brokers
10
+ from tradingapi.fivepaisa import FivePaisa
11
+ from tradingapi import fivepaisa as fivepaisa_module
12
+
13
+
14
+ class _SocketState:
15
+ connected = True
16
+
17
+
18
+ class _WebSocket:
19
+ def __init__(self, sent_requests):
20
+ self.sock = _SocketState()
21
+ self.on_open = None
22
+ self.sent_requests = sent_requests
23
+
24
+ def send(self, payload):
25
+ self.sent_requests.append(json.loads(payload))
26
+
27
+
28
+ class _DelayedStreamingApi:
29
+ def __init__(self, connect_started, allow_connect, stop_stream):
30
+ self.ws = None
31
+ self.connect_started = connect_started
32
+ self.allow_connect = allow_connect
33
+ self.stop_stream = stop_stream
34
+ self.connect_calls = 0
35
+ self.sent_requests = []
36
+
37
+ def Request_Feed(self, feed_type, operation, symbols):
38
+ return {"feed": feed_type, "operation": operation, "symbols": symbols}
39
+
40
+ def connect(self, _request):
41
+ self.connect_calls += 1
42
+ self.connect_started.set()
43
+ assert self.allow_connect.wait(timeout=2)
44
+ self.ws = _WebSocket(self.sent_requests)
45
+
46
+ def error_data(self, callback):
47
+ self.error_callback = callback
48
+
49
+ def receive_data(self, callback):
50
+ callback(self.ws, json.dumps({"Token": 1, "LastRate": 100.0}))
51
+ self.stop_stream.wait(timeout=2)
52
+
53
+ def close_data(self):
54
+ if self.ws is not None:
55
+ self.ws.sock.connected = False
56
+
57
+
58
+ def _make_broker(api, fresh_login):
59
+ broker = object.__new__(FivePaisa)
60
+ broker.api = api
61
+ broker.broker = Brokers.FIVEPAISA
62
+ broker.account_key = broker.broker.name
63
+ broker.subscribe_thread = None
64
+ broker.subscribed_symbols = []
65
+ broker._stream_reconnect_lock = threading.Lock()
66
+ broker._stream_reconnect_serial_lock = threading.Lock()
67
+ broker._stream_subscriptions_lock = threading.Lock()
68
+ broker._stream_reconnect_active = False
69
+ broker._suppress_stream_reconnect = False
70
+ broker._last_stream_tick_ts = time.time() - 1
71
+ broker._fp_susertoken_path = "/tmp/fivepaisa-token"
72
+ broker._fp_restore_session_from_token = lambda _path: True
73
+ broker._fp_fresh_login = fresh_login
74
+ broker._wait_for_stream_request_rate_limit = lambda: None
75
+ broker.map_exchange_for_api = lambda _symbol, _exchange: "N"
76
+ broker.map_exchange_for_db = lambda _symbol, exchange: exchange
77
+ broker.exchange_mappings = {
78
+ "N": {
79
+ "symbol_map": {"TEST": 1, "TEST2": 2},
80
+ "symbol_map_reversed": {1: "TEST", 2: "TEST2"},
81
+ "exchangetype_map": {"TEST": "C", "TEST2": "C"},
82
+ }
83
+ }
84
+ return broker
85
+
86
+
87
+ def test_reconnect_waits_for_startup_and_coalesces_queued_callers():
88
+ connect_started = threading.Event()
89
+ allow_connect = threading.Event()
90
+ stop_stream = threading.Event()
91
+ api = _DelayedStreamingApi(connect_started, allow_connect, stop_stream)
92
+ fresh_login_calls = []
93
+ broker = _make_broker(api, lambda path: fresh_login_calls.append(path))
94
+ errors = []
95
+
96
+ def start_stream(symbol):
97
+ try:
98
+ broker.start_quotes_streaming("s", [symbol], exchange="NSE")
99
+ except Exception as exc:
100
+ errors.append(exc)
101
+
102
+ first_caller = threading.Thread(target=start_stream, args=("TEST",))
103
+ second_caller = threading.Thread(target=start_stream, args=("TEST2",))
104
+ with patch.object(fivepaisa_module.config, "get", return_value="/tmp/fivepaisa-token"):
105
+ first_caller.start()
106
+ assert connect_started.wait(timeout=2)
107
+ second_caller.start()
108
+ time.sleep(0.05)
109
+ allow_connect.set()
110
+ first_caller.join(timeout=2)
111
+ second_caller.join(timeout=2)
112
+ broker.start_quotes_streaming("s", ["TEST"], exchange="NSE")
113
+
114
+ stop_stream.set()
115
+ if broker.subscribe_thread is not None:
116
+ broker.subscribe_thread.join(timeout=2)
117
+
118
+ assert not first_caller.is_alive()
119
+ assert not second_caller.is_alive()
120
+ assert not errors
121
+ assert api.connect_calls == 1
122
+ assert fresh_login_calls == ["/tmp/fivepaisa-token"]
123
+ assert broker.subscribed_symbols == ["TEST", "TEST2"]
124
+ assert api.sent_requests == [
125
+ {
126
+ "feed": "mf",
127
+ "operation": "s",
128
+ "symbols": [{"Exch": "N", "ExchType": "C", "ScripCode": 2}],
129
+ },
130
+ {
131
+ "feed": "mf",
132
+ "operation": "s",
133
+ "symbols": [{"Exch": "N", "ExchType": "C", "ScripCode": 1}],
134
+ },
135
+ ]
@@ -351,6 +351,7 @@ class FivePaisa(BrokerBase):
351
351
  self._fp_fresh_login: Optional[Callable[[str], None]] = None
352
352
  self._stream_reconnect_lock = threading.Lock()
353
353
  self._stream_reconnect_serial_lock = threading.Lock()
354
+ self._stream_subscriptions_lock = threading.Lock()
354
355
  self._stream_reconnect_active = False
355
356
  self._suppress_stream_reconnect = False
356
357
  self._last_stream_tick_ts = 0.0
@@ -3392,24 +3393,31 @@ class FivePaisa(BrokerBase):
3392
3393
  context = create_error_context(date_string=date_string, date_string_type=type(date_string))
3393
3394
  raise ValidationError("Invalid date string", context)
3394
3395
 
3396
+ def _stale_market_open_timestamp() -> str:
3397
+ now_ist = get_tradingapi_now()
3398
+ if now_ist.tzinfo is not None:
3399
+ now_ist = now_ist.astimezone(dt.timezone(dt.timedelta(hours=5, minutes=30))).replace(tzinfo=None)
3400
+ market_open = dt.datetime.combine(now_ist.date(), dt.time(9, 15))
3401
+ return market_open.strftime("%Y-%m-%d %H:%M:%S")
3402
+
3395
3403
  # Extract the timestamp using regex (allow negative for .NET min-date, treated as invalid)
3396
3404
  match = re.search(r"/Date\((-?\d+)\)/", date_string)
3397
3405
  if not match:
3398
3406
  trading_logger.log_warning(
3399
3407
  "Invalid date format", {"date_string": date_string, "expected_format": "/Date(milliseconds)/"}
3400
3408
  )
3401
- return get_tradingapi_now().strftime("%Y-%m-%d %H:%M:%S")
3409
+ return _stale_market_open_timestamp()
3402
3410
 
3403
3411
  try:
3404
3412
  # Convert the timestamp from milliseconds to seconds
3405
3413
  timestamp_ms = int(match.group(1))
3406
- # Treat negative timestamps (e.g. .NET min date) as invalid; substitute current time
3414
+ # Treat negative timestamps (e.g. .NET min date) as invalid; substitute market-open stale time
3407
3415
  if timestamp_ms < 0:
3408
3416
  trading_logger.log_info(
3409
3417
  "Invalid date (negative timestamp)",
3410
3418
  {"date_string": date_string, "expected_format": "/Date(milliseconds)/"},
3411
3419
  )
3412
- return get_tradingapi_now().strftime("%Y-%m-%d %H:%M:%S")
3420
+ return _stale_market_open_timestamp()
3413
3421
  timestamp_s = timestamp_ms / 1000
3414
3422
 
3415
3423
  # Convert to UTC datetime
@@ -3429,18 +3437,28 @@ class FivePaisa(BrokerBase):
3429
3437
  return result
3430
3438
 
3431
3439
  except (ValueError, OverflowError) as e:
3432
- context = create_error_context(
3433
- date_string=date_string,
3434
- timestamp_ms=timestamp_ms if "timestamp_ms" in locals() else None,
3435
- error=str(e),
3440
+ trading_logger.log_warning(
3441
+ "Error converting timestamp; using market-open stale timestamp",
3442
+ {
3443
+ "date_string": date_string,
3444
+ "timestamp_ms": timestamp_ms if "timestamp_ms" in locals() else None,
3445
+ "error": str(e),
3446
+ },
3436
3447
  )
3437
- raise DataError(f"Error converting timestamp: {str(e)}", context)
3448
+ return _stale_market_open_timestamp()
3438
3449
 
3439
3450
  except (ValidationError, DataError):
3440
- raise
3451
+ trading_logger.log_warning(
3452
+ "Validation/data error converting timestamp; using market-open stale timestamp",
3453
+ {"date_string": date_string},
3454
+ )
3455
+ return _stale_market_open_timestamp()
3441
3456
  except Exception as e:
3442
- context = create_error_context(date_string=date_string, error=str(e))
3443
- raise DataError(f"Unexpected error converting to IST: {str(e)}", context)
3457
+ trading_logger.log_warning(
3458
+ "Unexpected error converting timestamp; using market-open stale timestamp",
3459
+ {"date_string": date_string, "error": str(e)},
3460
+ )
3461
+ return _stale_market_open_timestamp()
3444
3462
 
3445
3463
  @log_execution_time
3446
3464
  @validate_inputs(
@@ -3760,7 +3778,14 @@ class FivePaisa(BrokerBase):
3760
3778
  if json_data.get("TickDt"):
3761
3779
  price.timestamp = self.convert_to_ist(json_data["TickDt"])
3762
3780
  else:
3763
- price.timestamp = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
3781
+ now_ist = get_tradingapi_now()
3782
+ if now_ist.tzinfo is not None:
3783
+ now_ist = now_ist.astimezone(dt.timezone(dt.timedelta(hours=5, minutes=30))).replace(
3784
+ tzinfo=None
3785
+ )
3786
+ price.timestamp = dt.datetime.combine(now_ist.date(), dt.time(9, 15)).strftime(
3787
+ "%Y-%m-%d %H:%M:%S"
3788
+ )
3764
3789
  prices[resolved_symbol] = price
3765
3790
  return price
3766
3791
  except Exception as e:
@@ -3939,9 +3964,13 @@ class FivePaisa(BrokerBase):
3939
3964
  and is_ws_connected(ws)
3940
3965
  )
3941
3966
 
3942
- def reconnect_stream():
3967
+ def reconnect_stream() -> bool:
3943
3968
  """Start a fresh websocket and restore all subscriptions."""
3944
3969
  with self._stream_reconnect_serial_lock:
3970
+ # Another caller may have restored the stream while this caller
3971
+ # waited for the serial reconnect lock.
3972
+ if has_live_stream():
3973
+ return False
3945
3974
  previous_tick_ts = self._last_stream_tick_ts
3946
3975
  try:
3947
3976
  self._suppress_stream_reconnect = True
@@ -3970,7 +3999,9 @@ class FivePaisa(BrokerBase):
3970
3999
  # rejects streaming on a token-restored session — subscriptions are accepted
3971
4000
  # but no ticks are ever delivered. fresh_fn gets a genuine new session.
3972
4001
  fresh_fn(token_path)
3973
- req_list_full = expand_symbols_to_request(self.subscribed_symbols)
4002
+ with self._stream_subscriptions_lock:
4003
+ subscribed_symbols = list(self.subscribed_symbols)
4004
+ req_list_full = expand_symbols_to_request(subscribed_symbols)
3974
4005
  if not req_list_full:
3975
4006
  context = create_error_context(operation=operation, symbols=symbols, exchange=exchange)
3976
4007
  raise MarketDataError("No symbols to reconnect after socket closure", context)
@@ -3982,31 +4013,34 @@ class FivePaisa(BrokerBase):
3982
4013
  copy.deepcopy(self.api.Request_Feed("oi", "s", oi_req_list_full))
3983
4014
  ] if oi_req_list_full else []
3984
4015
  reconnect_started_ts = time.time()
3985
- self.subscribe_thread = threading.Thread(
4016
+ stream_thread = threading.Thread(
3986
4017
  target=connect_and_receive,
3987
4018
  args=(req_data_full, extra_req_data_full),
3988
4019
  name="MarketDataStreamer",
3989
4020
  )
3990
- self.subscribe_thread.start()
3991
- should_verify_ticks = previous_tick_ts > 0 and bool(self.subscribed_symbols)
3992
- if should_verify_ticks:
3993
- deadline = time.time() + _stream_reconnect_verify_timeout_secs
3994
- while time.time() < deadline:
3995
- if self._last_stream_tick_ts >= reconnect_started_ts:
3996
- break
3997
- if not has_live_stream():
3998
- raise BrokerConnectionError("WebSocket thread died during reconnect verification")
3999
- time.sleep(0.5)
4000
- else:
4021
+ self.subscribe_thread = stream_thread
4022
+ stream_thread.start()
4023
+ should_verify_ticks = previous_tick_ts > 0 and bool(subscribed_symbols)
4024
+ deadline = time.time() + _stream_reconnect_verify_timeout_secs
4025
+ while time.time() < deadline:
4026
+ if should_verify_ticks and self._last_stream_tick_ts >= reconnect_started_ts:
4027
+ break
4028
+ if not should_verify_ticks and has_live_stream():
4029
+ break
4030
+ if not stream_thread.is_alive():
4031
+ raise BrokerConnectionError("WebSocket thread died during reconnect verification")
4032
+ time.sleep(0.1)
4033
+ else:
4034
+ if should_verify_ticks:
4001
4035
  raise BrokerConnectionError(
4002
4036
  "WebSocket reconnected but remained silent after reconnect verification"
4003
4037
  )
4004
- else:
4005
- time.sleep(2)
4038
+ raise BrokerConnectionError("WebSocket did not connect during reconnect verification")
4006
4039
  trading_logger.log_info(
4007
4040
  "Reconnected after socket closure",
4008
- {"subscribed_count": len(self.subscribed_symbols), "verified_ticks": should_verify_ticks},
4041
+ {"subscribed_count": len(subscribed_symbols), "verified_ticks": should_verify_ticks},
4009
4042
  )
4043
+ return True
4010
4044
 
4011
4045
  def send_stream_request(req_data):
4012
4046
  """Send an incremental subscribe/unsubscribe over an existing websocket."""
@@ -4079,17 +4113,21 @@ class FivePaisa(BrokerBase):
4079
4113
  def update_current_subscriptions(operation, symbols):
4080
4114
  """Update current subscriptions."""
4081
4115
  try:
4082
- if operation == "s":
4083
- self.subscribed_symbols.extend(symbols)
4084
- trading_logger.log_info(
4085
- "Symbols subscribed", {"symbols": symbols, "total_subscribed": len(self.subscribed_symbols)}
4086
- )
4087
- elif operation == "u":
4088
- self.subscribed_symbols = list(set(self.subscribed_symbols) - set(symbols))
4089
- trading_logger.log_info(
4090
- "Symbols unsubscribed",
4091
- {"symbols": symbols, "total_subscribed": len(self.subscribed_symbols)},
4092
- )
4116
+ with self._stream_subscriptions_lock:
4117
+ if operation == "s":
4118
+ self.subscribed_symbols = list(dict.fromkeys([*self.subscribed_symbols, *symbols]))
4119
+ message = "Symbols subscribed"
4120
+ else:
4121
+ removed_symbols = set(symbols)
4122
+ self.subscribed_symbols = [
4123
+ symbol for symbol in self.subscribed_symbols if symbol not in removed_symbols
4124
+ ]
4125
+ message = "Symbols unsubscribed"
4126
+ total_subscribed = len(self.subscribed_symbols)
4127
+ trading_logger.log_info(
4128
+ message,
4129
+ {"symbols": symbols, "total_subscribed": total_subscribed},
4130
+ )
4093
4131
  except Exception as e:
4094
4132
  trading_logger.log_error(
4095
4133
  "Error updating subscriptions", e, {"operation": operation, "symbols": symbols}
@@ -4111,7 +4149,10 @@ class FivePaisa(BrokerBase):
4111
4149
  if not has_live_stream():
4112
4150
  # WS is dead — full reconnect restoring all subscribed_symbols (already updated above).
4113
4151
  # Connecting with just the current op's req_data would lose all prior subscriptions.
4114
- reconnect_stream()
4152
+ if not reconnect_stream():
4153
+ send_stream_request(req_data)
4154
+ if oi_req_data:
4155
+ send_stream_request(oi_req_data)
4115
4156
  else:
4116
4157
  trading_logger.log_info(
4117
4158
  "Requesting streaming for existing connection", {"req_data": json.dumps(req_data)}
@@ -4125,8 +4166,10 @@ class FivePaisa(BrokerBase):
4125
4166
  "WebSocket closed, reconnecting...",
4126
4167
  {"operation": operation, "symbols_count": len(symbols), "exchange": exchange},
4127
4168
  )
4128
- reconnect_stream()
4129
- # New connection already has full subscription; no need to send again
4169
+ if not reconnect_stream():
4170
+ send_stream_request(req_data)
4171
+ if oi_req_data:
4172
+ send_stream_request(oi_req_data)
4130
4173
  else:
4131
4174
  trading_logger.log_warning(
4132
4175
  "No valid request list generated", {"symbols": symbols, "req_list": req_list}